Saltar al contenido
SuperScheduler

DeporteCourt Club (Negocio ficticio)

Reservas de pistas en un club deportivo

A media mañana se rompe la red de una pista de pádel. Mueve la clase, cierra la pista y respeta el turno de cada monitor.

Escala temporal
Demo con datos sintéticos
minutos / horas
Capacidades Pro
TemasMenú contextualReglas de movimientoTiempo bloqueadoZoom continuo
Court Club
Pista 1 · Tierra17 %Pista 2 · Tierra18 %Pista 3 · Dura18 %Pádel 125 %Pádel 220 %Pádel 315 %Sala A · Bicis10 %Sala B · Esterillas10 %Monitora Iris50 %Monitor Mateo30 %Monitora Noor59 %Barrido de líneasTenis · IrisTenis · Reserva de socioTenis · IrisBarrido de líneasTenis · Reserva de socioTenis · MateoTenis · Reserva de socioTenis · Reserva de socioPickleball · Reserva de socioTenis · MateoPádel · Reserva de socioPádel · NoorPádel · Reserva de socioPádel · Reserva de socioPádel · Reserva de socioPádel · NoorPádel · Reserva de socioPádel · Reserva de socioPádel · Reserva de socioCiclo · IrisCiclo · NoorYoga · IrisPilates · NoorDe turnoDe turnoDe turnoDe turnoDe turnosábado, 17 de octubre07:0007:3008:0008:3009:0009:3010:0010:3011:0011:3012:0012:3013:0013:3014:0014:3015:0015:3016:0016:3017:0017:3018:00

Carga SuperScheduler Pro solo cuando lo pides.

La situación

Court Club es un club ficticio de raqueta y fitness con tres pistas de tenis, tres de pádel, una sala de ciclo y una sala de esterillas. Los socios reservan por medias horas y los monitores dan clase en cualquier superficie de su deporte, pero solo dentro de su turno.

En recepción el fin de semana cambia a cada minuto: se rompe una red, una clase pasa de Yoga a Pilates, a una pista hay que barrerle las líneas. El cuadrante tiene que rechazar una clase de pádel en tierra batida, un monitor en dos sitios a la vez y una reserva encima de otra, y ofrecer las acciones justo donde está mirando quien atiende.

Qué vas a hacerMoverás una clase de pádel a una pista libre, cerrarás la pista averiada para la reparación desde un menú contextual y cambiarás la actividad de una clase de sala, con cada rechazo explicado.

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

SuperScheduler aporta

  • Rejillas de media hora y de hora con solo el horario de apertura, y un cambio animado entre ellas.
  • Arrastre y redimensionado con rechazo de solapes en cada fila y resaltado de la reserva que estorba.
  • Menús contextuales SuperScheduler.Menu en reservas y en tramos seleccionados, con submenús, opciones desactivadas y Mayús+F10.
  • La tarjeta de arrastre que explica por qué se rechaza una suelta, y movimientos con teclado con anuncios.
  • Una piel hecha con tokens --super-scheduler-* sobre una clase raíz, limitada a este cuadrante.

El código de este ejemplo decide

  • Qué superficie admite cada actividad y la frase que se muestra en cada rechazo.
  • Comprobaciones del monitor entre filas: que esté de turno y que no dé otra clase a la misma hora.
  • Qué hace cada acción del menú y qué duraciones caben antes de la siguiente reserva o del cierre.
  • La ocupación en las cabeceras de fila, el panel de detalle y todos los textos de esta página.

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: dos niveles de rejilla, solo el horario de apertura, las comprobaciones al mover y redimensionar, y los dos menús rellenados al abrirse con las reservas actuales.

scheduler.tsxtsx
/**
 * Court Club booking board: courts, studios and the coaches' rota over one weekend.
 *
 * Library: half-hour and hourly grids, drag/resize, per-row overlap refusal with conflict
 * highlight, SuperScheduler.Menu context menus, keyboard (Shift+F10 opens the menus) and a skin
 * made of CSS tokens scoped to this board (./skin.css).
 * Application: which surface hosts which activity, whether the coach is on duty and free, and what
 * each menu command does to the bookings. Those rules live in ./rules.
 */
import { useEffect, useLayoutEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { plural } from '~/i18n/format'
import { MINUTE, formatCivil, minutesBetween, type CivilDateTime } from '../../scheduler/civil'
import { usesTwelveHours } from '../../scheduler/header-format'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
  civilOf,
  isVenueId,
  itemLabel,
  itemTone,
  readClubFields,
  rowMetrics,
  toResources,
  type ClubEvent,
} from './adapter'
import {
  OPENING_HOURS,
  WEEKEND,
  type Activity,
  type ClubItem,
  type RowId,
  type VenueId,
} from './model'
import {
  ACTIVITIES_BY_VENUE,
  LENGTHS,
  canHost,
  coachIssue,
  lengthIssue,
  venueConflicts,
  venueKind,
  type Length,
} from './rules'
import type { CourtClubText, ZoomId } from './text'
import './skin.css'

/** Menu commands. The demo applies them to its React state, the single source of truth. */
export interface ClubActions {
  readonly switchActivity: (id: string, activity: Activity) => void
  readonly setLength: (id: string, minutes: Length) => void
  readonly cancel: (id: string) => void
  readonly reopen: (id: string) => void
  readonly block: (venue: VenueId, start: CivilDateTime, end: CivilDateTime) => void
  readonly hire: (venue: VenueId, start: CivilDateTime, end: CivilDateTime) => void
}

export interface CourtClubSchedulerProps {
  readonly events: ClubEvent[]
  /** The same data read back as the club's model, for the rules. */
  readonly items: readonly ClubItem[]
  readonly text: CourtClubText
  /** 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 controlRef: RefObject<SuperScheduler.Scheduler | null>
  /** Receives the booking menu, so the detail panel's "Options" button can open it too. */
  readonly menuRef: RefObject<SuperScheduler.Menu | null>
  readonly actions: ClubActions
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string) => void
  /** A refusal while dragging (null once the target is valid again) or after a keyboard move. */
  readonly onRefused: (message: string | null) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'half-hours',
    properties: {
      scale: 'CellDuration',
      cellDuration: 30,
      cellWidth: 56,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
    },
  },
  {
    id: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 64,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['half-hours', 'hours']

/** Rows that start a new family of venues get a court line above them. */
const SECTION_STARTS: ReadonlySet<SuperScheduler.ResourceId> = new Set<RowId>([
  'padel-1',
  'studio-a',
  'coach-iris',
])

/** What the menus need when they open; refreshed by the board after every render. */
interface MenuContext {
  readonly items: CourtClubSchedulerProps['items']
  readonly text: CourtClubSchedulerProps['text']
  readonly actions: ClubActions
  readonly tag: string
  readonly clearSelection: () => void
}

/**
 * Both menus are created once, outside render. What they offer is decided when they open, from
 * the context the board hands over after each render.
 */
function createClubMenus() {
  let context: MenuContext | null = null
  const selection = new SuperScheduler.Menu({
    theme: 'cc_menu',
    showMenuTitle: true,
    onShow: (args) => {
      const source = args.source
      if (
        !(source instanceof SuperScheduler.Selection) ||
        source.resource === undefined ||
        !isVenueId(source.resource)
      ) {
        args.preventDefault()
        return
      }
      if (context === null) {
        args.preventDefault()
        return
      }
      const { items: current, text: t, actions: run, tag: lang } = context
      const venue = source.resource
      const start = civilOf(source.start)
      const end = civilOf(source.end)
      const free = venueConflicts(venue, { start, end }, current).length === 0
      const courts = venueKind(venue) !== 'bikes' && venueKind(venue) !== 'mats'
      args.menu.menuTitle = `${t.rows[venue]} · ${timeRange(start, end, lang)}`
      args.menu.items = [
        { text: t.menu.block, disabled: !free, onClick: () => run.block(venue, start, end) },
        {
          text: t.menu.hire,
          disabled: !free,
          hidden: !courts,
          onClick: () => run.hire(venue, start, end),
        },
      ]
    },
    // A held selection would linger under the next booking: clear it whatever was chosen.
    onHide: () => context?.clearSelection(),
  })
  const booking = new SuperScheduler.Menu({
    theme: 'cc_menu',
    showMenuTitle: true,
    onShow: (args) => {
      const source = args.source
      const fields = source instanceof SuperScheduler.Event ? readClubFields(source.data) : null
      // The rota has no commands: no menu for shifts.
      if (!(source instanceof SuperScheduler.Event) || fields === null || fields.kind === 'shift') {
        args.preventDefault()
        return
      }
      if (context === null) {
        args.preventDefault()
        return
      }
      const { items: current, text: t, actions: run, tag: lang } = context
      const id = String(source.id())
      const item = current.find((candidate) => candidate.id === id)
      if (item === undefined || item.kind === 'shift') {
        args.preventDefault()
        return
      }
      const { title, detail } = itemLabel(fields, t)
      args.menu.menuTitle = detail === '' ? title : `${title} · ${detail}`
      if (item.kind === 'block') {
        args.menu.items = [{ text: t.menu.endBlock, onClick: () => run.reopen(id) }]
        return
      }
      const choices: readonly Activity[] = ACTIVITIES_BY_VENUE[venueKind(item.venue)]
      const length = minutesBetween(item.start, item.end)
      args.menu.items = [
        {
          text: t.menu.activity,
          disabled: choices.length < 2,
          items: choices.map((activity) => ({
            text: t.activities[activity],
            disabled: activity === item.activity,
            ...(activity === item.activity ? { cssClass: 'cc-menu__current' } : {}),
            onClick: () => run.switchActivity(id, activity),
          })),
        },
        {
          text: t.menu.length,
          // Each length is checked against the venue, the coach and closing time before it is offered.
          items: LENGTHS.map((minutes) => {
            const issue = minutes === length ? null : lengthIssue(item, minutes, current)
            const label = SuperScheduler.Util.escapeHtml(plural(t.minutes, minutes, lang))
            const why =
              issue === null
                ? ''
                : `<span class="cc-menu__why">${SuperScheduler.Util.escapeHtml(t.menuReasons[issue])}</span>`
            return {
              html: `<span>${label}</span>${why}`,
              disabled: minutes === length || issue !== null,
              ...(minutes === length ? { cssClass: 'cc-menu__current' } : {}),
              onClick: () => run.setLength(id, minutes),
            }
          }),
        },
        { text: '-' },
        { text: t.menu.cancel, cssClass: 'cc-menu__danger', onClick: () => run.cancel(id) },
      ]
    },
  })
  return {
    selection,
    booking,
    setContext: (next: MenuContext) => {
      context = next
    },
  }
}

export function CourtClubScheduler(props: CourtClubSchedulerProps) {
  const { events, items, text, tag, schedulerLocale, initialZoom, controlRef, menuRef, actions } =
    props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

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

  const [menus] = useState(createClubMenus)
  useLayoutEffect(() => {
    menus.setContext({
      items,
      text,
      actions,
      tag,
      clearSelection: () => controlRef.current?.clearSelection(),
    })
  }, [menus, items, text, actions, tag, controlRef])

  // Hand the booking menu to the demo; close any open menu when the board goes away.
  useEffect(() => {
    menuRef.current = menus.booking
    return () => {
      menuRef.current = null
      menus.booking.hide()
      menus.selection.hide()
    }
  }, [menus, menuRef])

  // Live metrics for the row headers: booked share of each venue, teaching share of each coach.
  const metrics = useMemo(() => rowMetrics(items, text), [items, text])

  // Header labels follow each cell's span, so they stay right during the animated 30/60 switch.
  const headerHook = useMemo(
    () =>
      createSpanHeaderHook(tag, (span, level) =>
        level === 'group' ? 'day' : span <= 30 * MINUTE ? 'half-hour' : 'hour',
      ),
    [tag],
  )

  const config = useMemo<SchedulerProps>(() => {
    const clock = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
    return {
      locale: schedulerLocale,
      startDate: WEEKEND.start,
      days: WEEKEND.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // The club is closed at night: only opening hours are on the board. Weekends are business days.
      businessBeginsHour: OPENING_HOURS.begin,
      businessEndsHour: OPENING_HOURS.end,
      businessWeekends: true,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      rowHeaderWidth: 196,
      rowHeaderWidthAutoFit: false,
      eventHeight: 30,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      // Theming: one class on the control root carries the club's tokens (./skin.css).
      cssClass: 'cc-skin',
      allowEventOverlap: false,
      conflictHighlight: true,
      dragCard: {
        duration: false,
        dateFormat: clock,
        movingDateFormat: clock,
        markerFormat: clock,
      },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      contextMenu: menus.booking,
      contextMenuSelection: menus.selection,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        const id = String(args.row.id)
        const name = SuperScheduler.Util.escapeHtml(args.row.name)
        const metric = SuperScheduler.Util.escapeHtml(metrics.get(id) ?? '')
        const label = SuperScheduler.Util.escapeHtml(text.occupancyLabel)
        args.row.html = `<span class="cc-rh"><span class="cc-rh__name">${name}</span><span class="cc-rh__meta" title="${label}">${metric}</span></span>`
        const classes = id.startsWith('coach-') ? ['cc-rh--coach'] : []
        if (SECTION_STARTS.has(args.row.id)) classes.push('cc-rh--section')
        args.row.cssClass = classes.join(' ')
      },

      onBeforeEventRender: (args) => {
        const fields = readClubFields(args.data)
        if (fields === null) return
        const { title, detail } = itemLabel(fields, text)
        const meta =
          detail === ''
            ? ''
            : `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(detail)}</span>`
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(title)}</span>${meta}</span>`
        const classes = ['ss-ev', toneClass(itemTone(fields)), 'cc-ev', `cc-ev--${fields.kind}`]
        if (fields.kind === 'booking' && fields.format === 'class') classes.push('cc-ev--class')
        if (fields.kind === 'block') classes.push('ss-ev--hatched')
        if (fields.kind === 'shift') classes.push('ss-ev--ghost')
        args.data.cssClass = classes.join(' ')
      },

      // Surface and coach rules run on every shadow change; overlap inside a row is the library's.
      onEventMoving: (args) => {
        const refusal = moveRefusal(
          args.e.data,
          args.resource,
          civilOf(args.start),
          civilOf(args.end),
          items,
          text,
        )
        if (refusal !== null) {
          args.allowed = false
          args.message = refusal
        } else if (args.conflicts.length > 0) {
          args.message = text.rules.occupied
        }
        onRefused(refusal ?? (args.conflicts.length > 0 ? text.rules.occupied : null))
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const refusal = moveRefusal(
          args.e.data,
          args.newResource,
          civilOf(args.newStart),
          civilOf(args.newEnd),
          items,
          text,
        )
        if (refusal !== null) {
          args.preventDefault()
          onRefused(refusal)
        }
      },

      onEventResizing: (args) => {
        const refusal = moveRefusal(
          args.e.data,
          args.e.resource(),
          civilOf(args.start),
          civilOf(args.end),
          items,
          text,
        )
        if (refusal !== null) {
          args.allowed = false
          args.message = refusal
        }
        onRefused(refusal)
      },

      onEventResize: (args) => {
        const refusal = moveRefusal(
          args.e.data,
          args.e.resource(),
          civilOf(args.newStart),
          civilOf(args.newEnd),
          items,
          text,
        )
        if (refusal !== null) {
          args.preventDefault()
          onRefused(refusal)
        }
      },

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

      // Coach rows show the rota: nothing is booked on them.
      onTimeRangeSelecting: (args) => {
        if (!isVenueId(args.resource)) args.allowed = false
      },

      // Dragging across free time offers the slot's commands straight away.
      onTimeRangeSelected: (args) => {
        if (args.origin !== 'drag' || !isVenueId(args.resource)) return
        menus.selection.show(new SuperScheduler.Selection(args.start, args.end, args.resource), {
          owner: args.control,
        })
      },

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

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

/**
 * Why the club refuses `data` at `resource` from `start` to `end`, or null. Blocks and the rota are
 * fixed; a booking needs a surface for its activity and, for a class, a coach on duty and free.
 */
function moveRefusal(
  data: object,
  resource: SuperScheduler.ResourceId,
  start: CivilDateTime,
  end: CivilDateTime,
  items: readonly ClubItem[],
  text: CourtClubText,
): string | null {
  const fields = readClubFields(data)
  if (fields === null) return null
  if (fields.kind !== 'booking') return text.rules.fixed
  if (!isVenueId(resource)) return text.rules.coachRow
  if (!canHost(resource, fields.activity)) return text.rules.wrongVenue[fields.activity]
  if (fields.coach === undefined) return null
  const id = 'id' in data ? String(data.id) : undefined
  const issue = coachIssue(fields.coach, { ...(id === undefined ? {} : { id }), start, end }, items)
  if (issue === 'off-duty') return text.rules.offDuty
  if (issue === 'double-booked') return text.rules.doubleBooked
  return null
}

/** "11:30 AM – 1:00 PM" / "11:30 – 13:00" in the page's language. */
export function timeRange(start: string, end: string, tag: string): string {
  const options: Intl.DateTimeFormatOptions = usesTwelveHours(tag)
    ? { hour: 'numeric', minute: '2-digit' }
    : { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }
  return `${formatCivil(start, tag, options)} – ${formatCivil(end, tag, options)}`
}
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 relacionadoCitas de clínica de fisioterapiaUn paciente no puede venir a las 10:00. Encuentra el siguiente hueco que respete pausas y limpiezas.Leer el caso de negocioRecintos y eventos