Saltar al contenido
SuperScheduler

AlojamientoCasa Noma (Negocio ficticio)

Planning de habitaciones de hotel

Gotea la ducha de la 104. Recoloca al siguiente cliente, bloquea la habitación para el fontanero y localiza las noches que ya están llenas.

Escala temporal
Demo con datos sintéticos
días / meses
Capacidades Pro
Árbol de recursosArrastrar y redimensionarMinimapaTiempo bloqueadoTemas
Casa Noma
Doble estándar101Calle102Calle103Patio104PatioDoble superior201Balcón202Balcón203PatioJunior suite301Terraza302TerrazaSuite jardín401JardínHaddadNovakFerreiraSatoBrandtO’NeillJensenDuboisTanakaLindgrenSilvaKayaOkaforBianchiPetrovVargaLaineMorenoFuera de servicioKellerNakamuraFischerOrtegaWrightCostaKimHaasIvanovaDuarteQuinnAlbersSousaVidalHartEriksenNavarroOlsenSalehRinaldiParkMercerAndersenGarcíaBecker26 oct2 nov9 nov16 novlun 26mar 27mié 28jue 29vie 30sáb 31dom 1lun 2mar 3mié 4jue 5vie 6sáb 7dom 8lun 9mar 10mié 11jue 12vie 13sáb 14dom 15lun 16mar 17

Carga SuperScheduler Pro solo cuando lo pides.

La situación

Casa Noma es un hotel boutique ficticio de diez habitaciones: cuatro dobles estándar, tres superiores, dos junior suites y una suite jardín. Recepción planifica en un planning con una fila por habitación y una columna por noche. Los clientes entran a las 14:00 y salen a las 11:00, así que el día de rotación una estancia termina y la siguiente empieza en la misma columna sin solaparse.

Las habitaciones se estropean. Cuando una sale de venta, recepción tiene que recolocar a los clientes asignados sin bajarles de categoría, encajar la reparación entre una salida y una entrada y vigilar las noches que ya están completas. Unas barras no distinguen una noche tranquila de una agotada; este planning cuenta las habitaciones libres por tipo y noche.

Qué vas a hacerMoverás una reserva fuera de una habitación averiada (un upgrade vale, una bajada de categoría se rechaza), alargarás la reparación a tres noches con horas reales de hotel y encontrarás las noches agotadas desde la franja de ocupación.

Dónde termina la librería y empieza tu aplicación

SuperScheduler aporta

  • Árbol de recursos con tipos de habitación y habitaciones; las filas de grupo muestran las libres por noche con onBeforeCellRender.
  • Estancias a la hora exacta sobre una escala de noches (useEventBoxes: 'Never') que se mueven por noches completas con eventMoveByCell.
  • Celdas deshabilitadas para la suite jardín cerrada, prevención de solapes y un redimensionado que se puede guiar en onEventResizing.
  • Minimapa alimentado con tu propia serie, niveles de zoom, movimientos con teclado e historial de deshacer.
  • Temas con tokens y clases limitados a la raíz del control (cssClass): estancias en forma de píldora, colores de estado y la columna de hoy.

El código de este ejemplo decide

  • Upgrades sí, bajadas de categoría no, capacidad de la habitación y nada de entradas en el pasado: rechazos en onEventMoving y onEventResizing.
  • Horas de hotel al redimensionar: las estancias terminan a las 11:00 y las reparaciones a las 14:00, nunca menos de una noche.
  • El estado de recepción de cada estancia (salida, en casa, llega hoy, confirmada, opción) a partir de la fecha de trabajo.
  • Ocupación por noche, habitaciones libres por tipo y noche y el resumen de las próximas siete noches.

El código de esta demo

Son los archivos que ejecuta esta página, no una copia simplificada. Compilan contra los exports públicos de SuperScheduler Pro.

La integración: escala de noches con horas exactas, celdas de disponibilidad, días deshabilitados y los callbacks donde las reglas de recepción rechazan o ajustan un cambio.

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
}
Descargar el código del ejemplo (.zip)El archivo contiene los ficheros de este ejemplo y un README. No incluye SuperScheduler Pro ni ninguna clave de descarga.

Comparte este escenario

El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.

Otro negocio, un problema relacionadoPlanificación de una flota de alquilerUn compacto queda inmovilizado el día de la recogida. Pasa su alquiler a otro coche, respeta la limpieza y mira cuándo se queda sin flota la oficina.Leer el caso de negocioAlojamiento y alquiler