Aller au contenu
SuperScheduler

HébergementCasa Noma (Entreprise fictive)

Planning des chambres d’hôtel

La douche de la 104 fuit. Relogez le prochain client, bloquez la chambre pour le plombier et repérez les nuits déjà complètes.

Échelle de temps
Démo avec données fictives
jours / mois
Fonctionnalités Pro
Arborescence de ressourcesGlisser et redimensionnerMinimapCréneaux bloquésThèmes
Casa Noma
Double standard101Rue102Rue103Cour104CourDouble supérieure201Balcon202Balcon203CourJunior suite301Terrasse302TerrasseSuite jardin401JardinHaddadNovakFerreiraSatoBrandtO’NeillJensenDuboisTanakaLindgrenSilvaKayaOkaforBianchiPetrovVargaLaineMorenoHors serviceKellerNakamuraFischerOrtegaWrightCostaKimHaasIvanovaDuarteQuinnAlbersSousaVidalHartEriksenNavarroOlsenSalehRinaldiParkMercerAndersenGarcíaBecker26 oct.2 nov.9 nov.16 nov.lun. 26mar. 27mer. 28jeu. 29ven. 30sam. 31dim. 1lun. 2mar. 3mer. 4jeu. 5ven. 6sam. 7dim. 8lun. 9mar. 10mer. 11jeu. 12ven. 13sam. 14dim. 15lun. 16mar. 17

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

La situation

Casa Noma est un hôtel de charme fictif de dix chambres : quatre doubles standard, trois supérieures, deux junior suites et une suite jardin. La réception planifie sur un planning à une ligne par chambre et une colonne par nuit. Les clients arrivent à 14:00 et partent à 11:00 : un jour de recouche, un séjour se termine et le suivant commence dans la même colonne sans chevauchement.

Les chambres tombent en panne. Quand l’une d’elles sort de la vente, la réception doit reloger les clients prévus sans les déclasser, caler les travaux entre un départ et une arrivée et surveiller les nuits déjà pleines. Des barres seules ne distinguent pas une nuit calme d’une nuit complète ; ce planning compte les chambres libres par type et par nuit.

Ce que vous allez faireVous déplacerez une réservation hors d’une chambre en panne (un surclassement est accepté, un déclassement refusé), prolongerez les travaux à trois nuits aux vraies heures de l’hôtel et trouverez les nuits complètes grâce à la bande d’occupation.

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

SuperScheduler fournit

  • Arborescence de ressources par type de chambre ; les lignes de groupe affichent les chambres libres par nuit via onBeforeCellRender.
  • Séjours à l’heure exacte sur une échelle de nuits (useEventBoxes: 'Never'), déplacés par nuits entières avec eventMoveByCell.
  • Cellules désactivées pour la suite jardin fermée, prévention des chevauchements et redimensionnement pilotable dans onEventResizing.
  • Minimap alimentée par votre propre série, niveaux de zoom, déplacements au clavier et historique d’annulation.
  • Thème par tokens et classes limités à la racine du contrôle (cssClass) : séjours en pilule, couleurs de statut, colonne du jour.

Le code de cet exemple décide

  • Surclassement oui, déclassement non, capacité de la chambre et pas d’arrivée dans le passé : les refus dans onEventMoving et onEventResizing.
  • Heures de l’hôtel au redimensionnement : les séjours finissent à 11:00, les travaux à 14:00, jamais moins d’une nuit.
  • Le statut de réception de chaque séjour (parti, en maison, arrivée du jour, confirmé, option) à partir de la date de travail.
  • L’occupation par nuit, les chambres libres par type et par nuit et le résumé des sept prochaines nuits.

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 : échelle de nuits aux heures exactes, cellules de disponibilité, jours désactivés et callbacks où les règles de réception refusent ou ajustent un changement.

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
}
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 d’une flotte de locationUne citadine est immobilisée le jour du départ. Confiez son contrat à une autre voiture, gardez le temps de préparation et voyez où la flotte manque.Lire le cas métierHébergement et location