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
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.
/**
* 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)}`
}Comparte este escenario
El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.