SportCourt Club (Entreprise fictive)
Réservation de courts dans un club sportif
En pleine matinée, le filet d’un court de padel lâche. Déplacez le stage, fermez le court et gardez chaque coach dans son service.
- Échelle de temps
- Démo avec données fictives
- minutes / heures
- Fonctionnalités Pro
- ThèmesMenu contextuelRègles de déplacementCréneaux bloquésZoom continu
SuperScheduler Pro n’est chargé que lorsque vous le demandez.
La situation
Court Club est un club fictif de raquette et de fitness avec trois courts de tennis, trois courts de padel, une salle de vélo et une salle de tapis. Les membres réservent à la demi-heure, et les coachs donnent cours sur toute surface de leur sport, mais seulement pendant leur service.
À l’accueil, le week-end change à chaque minute : un filet casse, un cours passe du yoga au Pilates, un court doit être balayé. Le planning doit refuser un cours de padel sur terre battue, un coach à deux endroits à la fois et une réservation par-dessus une autre, et proposer les bonnes commandes là où regarde la personne à l’accueil.
Ce que vous allez faireVous allez déplacer un cours de padel vers un court libre, fermer le court en panne pour sa réparation depuis un menu contextuel et changer l’activité d’un cours en salle, chaque refus étant expliqué.
Où s’arrête la bibliothèque et où commence votre application
SuperScheduler fournit
- Grilles à la demi-heure et à l’heure limitées aux heures d’ouverture, avec un passage animé de l’une à l’autre.
- Glisser et redimensionner avec refus des chevauchements dans chaque ligne et mise en évidence de la réservation gênante.
- Menus contextuels SuperScheduler.Menu sur les réservations et sur une plage sélectionnée, avec sous-menus, entrées désactivées et Maj+F10.
- La carte de glissement qui dit pourquoi un dépôt est refusé, et les déplacements au clavier avec annonces.
- Un habillage fait de jetons --super-scheduler-* sur une classe racine, limité à ce planning.
Le code de cet exemple décide
- Quelle surface accueille quelle activité, et la phrase affichée pour chaque refus.
- Les contrôles du coach d’une ligne à l’autre : en service, et pas déjà en cours ailleurs au même moment.
- Ce que fait chaque commande du menu, et quelles durées tiennent avant la réservation suivante ou la fermeture.
- L’occupation dans les en-têtes de ligne, le panneau de détail et tous les libellés de cette page.
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 : deux niveaux de grille, les heures d’ouverture seules, les contrôles au déplacement et au redimensionnement, et les deux menus remplis à l’ouverture avec les réservations à jour.
/**
* 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)}`
}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.