SantéForma (Entreprise fictive)
Rendez-vous d’un cabinet de kinésithérapie
Un patient ne peut pas venir à 10 h. Trouvez le prochain créneau qui respecte pauses et nettoyages.
- Échelle de temps
- Démo avec données fictives
- minutes / heures
- Fonctionnalités Pro
- Échelle à la minuteRègles de déplacementCréneaux bloquésSlots de rendu ReactPanneau de détail
SuperScheduler Pro n’est chargé que lorsque vous le demandez.
La situation
Forma est un cabinet fictif qui réunit trois kinésithérapeutes, un ostéopathe et une praticienne de massage sportif. Les rendez-vous durent de 15 à 60 minutes, chaque praticien travaille dans une salle, et la journée est coupée par des pauses, une réunion de synthèse et le nettoyage des salles entre deux séances.
Quand un patient appelle pour déplacer un rendez-vous, l’accueil n’a que quelques secondes pour trouver un créneau. Une grille papier ou un agenda générique laisse réserver sur une pause ou confier une séance d’ostéopathie à un kinésithérapeute. Le planning doit refuser ces créneaux et dire pourquoi, y compris à voix haute pour les lecteurs d’écran. Seuls des codes de rendez-vous et des types de soin apparaissent : cette démo ne contient aucune donnée de patient.
Ce que vous allez faireVous tenterez de placer un rendez-vous sur un créneau de nettoyage, entendrez pourquoi il est refusé, le déplacerez sur le premier créneau libre après le nettoyage et ajusterez sa durée par pas de 15 minutes.
Où s’arrête la bibliothèque et où commence votre application
SuperScheduler fournit
- Échelles à la minute : cellules de 30 et 15 minutes avec magnétisme, nuits masquées.
- Cellules désactivées qui refusent les dépôts, et vetos à chaque image (allowed, message) pendant le déplacement ou le redimensionnement.
- Une carte de déplacement personnalisée et du contenu React dans chaque rendez-vous via super-scheduler/react-render.
- Prévention des chevauchements par ligne et déplacements complets au clavier, avec annonces.
- Événements contrôlés via onEventsChange.
Le code de cet exemple décide
- Quelles disciplines peuvent assurer chaque soin.
- Les pauses, la réunion et le nettoyage des salles, et le praticien concerné par chacun.
- Une phrase par motif de refus, annoncée dans une région live polie quand un dépôt est refusé.
- Le panneau de détail, le temps réservé par jour, la mission et tous les textes 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 : tailles de créneau, cellules désactivées à partir des blocages, carte de déplacement, contenu React des rendez-vous et callbacks où les règles refusent un créneau.
/**
* Forma appointment board: practitioners over two clinic days in 30- or 15-minute slots.
*
* Library: minute-scale cells and snapping, disabled cells, drag/resize with per-frame vetoes,
* a custom drag card, React content inside events (super-scheduler/react-render), keyboard moves.
* Application: who may deliver which treatment, the clinic's fixed blocks and the sentence that
* explains every refusal. Those rules live in ./rules and run inside the callbacks below.
*/
import { CheckIcon, Clock3Icon } from 'lucide-react'
import { useMemo, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import { SuperSchedulerComponent, type SchedulerRenderProps } from 'super-scheduler/react-render'
import { MINUTE, civil, formatCivil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { useDropRefusal } from '../kit/use-drop-refusal'
import {
TREATMENT_TONES,
civilOf,
isAppointmentEvent,
practitionerOf,
readAppointmentFields,
toResources,
type AppointmentEvent,
} from './adapter'
import { BLOCKS, CLINIC_DAYS, OPENING_HOURS, type Appointment, type Status } from './model'
import { blockAt, slotRefusal, type Refusal } from './rules'
import { ZOOM_IDS, type FormaText, type ZoomId } from './text'
export interface FormaSchedulerProps {
readonly events: AppointmentEvent[]
readonly text: FormaText
/** BCP 47 tag for labels, and the scheduler locale id. */
readonly tag: string
readonly schedulerLocale: string
/** Slot size on mount. Later changes go through control.zoom so the view animates. */
readonly initialZoom: ZoomId
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) => void
readonly onRefused: (id: string, refusal: Refusal) => void
readonly onZoomLevel: (level: ZoomId) => void
}
/** Slot sizes: snapping follows the cell, so 15-minute cells move appointments by quarters. */
const SLOT_LEVELS: Readonly<Record<ZoomId, SuperScheduler.ZoomLevelProperties>> = {
fifteen: {
scale: 'CellDuration',
cellDuration: 15,
cellWidth: 56,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
thirty: {
scale: 'CellDuration',
cellDuration: 30,
cellWidth: 64,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
}
// Same order as ZOOM_IDS, so onZoom's level index maps back to an id.
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = ZOOM_IDS.map((id) => ({
id,
properties: SLOT_LEVELS[id],
}))
/** Every appointment the control holds right now, in the shape the rules read. */
function appointmentsIn(
control: SuperScheduler.Scheduler,
): Pick<Appointment, 'id' | 'practitioner' | 'start' | 'end'>[] {
return control.events.list.flatMap((event) => {
const practitioner = event.resource === undefined ? null : practitionerOf(event.resource)
if (practitioner === null || !isAppointmentEvent(event)) return []
return [
{
id: String(event.id),
practitioner: practitioner.id,
start: civilOf(event.start),
end: civilOf(event.end),
},
]
})
}
/** Checks one proposed slot of an appointment against the clinic's rules. */
function refusalFor(
control: SuperScheduler.Scheduler | null,
data: object,
id: string,
resource: SuperScheduler.ResourceId,
start: SuperScheduler.Date,
end: SuperScheduler.Date,
): Refusal | null {
const fields = readAppointmentFields(data)
const practitioner = practitionerOf(resource)
if (control === null || fields === null || practitioner === null) return null
return slotRefusal(
{ id, treatment: fields.treatment, start: civil(start.ticks), end: civil(end.ticks) },
practitioner,
BLOCKS,
appointmentsIn(control),
)
}
export function FormaScheduler(props: FormaSchedulerProps) {
const { events, text, tag, schedulerLocale, initialZoom, controlRef } = props
const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
// The drag card shows a refusal while the shadow sits on a forbidden slot; the hook announces
// the last reason when the pointer is released without a drop, so it is heard, not only seen.
const refusal = useDropRefusal(onRefused)
const config = useMemo<SchedulerRenderProps>(() => {
const time = (date: SuperScheduler.Date) =>
formatCivil(date.ticks, tag, { hour: 'numeric', minute: '2-digit' })
const day = (date: SuperScheduler.Date) => formatCivil(date.ticks, tag, { weekday: 'short' })
const escape = SuperScheduler.Util.escapeHtml
return {
locale: schedulerLocale,
startDate: CLINIC_DAYS.start,
days: CLINIC_DAYS.count,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// The clinic is closed at night: only opening hours are drawn.
businessBeginsHour: OPENING_HOURS.begin,
businessEndsHour: OPENING_HOURS.end,
showNonBusiness: false,
useEventBoxes: 'Never',
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
rowHeaderWidth: 176,
rowHeaderWidthAutoFit: false,
eventHeight: 44,
rowMarginTop: 4,
rowMarginBottom: 4,
allowEventOverlap: false,
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
controlRef,
onEventsChange,
// A receptionist reads minutes, not nights: the card shows the practitioner, the new
// times and, on a forbidden slot, the reason set in onEventMoving.
dragCard: {
html: (info) => {
const minutes = Math.round((info.end.ticks - info.start.ticks) / MINUTE)
const range = `${day(info.start)} ${time(info.start)}–${time(info.end)}`
const meta = info.refusal ?? text.durationShort.replace('{count}', String(minutes))
return (
`<div class="super-scheduler__drag-card-target">${escape(info.rowName ?? '')}</div>` +
`<div class="super-scheduler__drag-card-range"><span data-edge="moving">${escape(range)}</span></div>` +
`<div class="super-scheduler__drag-card-meta"><span class="super-scheduler__drag-card-meta-part">${escape(meta)}</span></div>`
)
},
},
onBeforeTimeHeaderRender: createHeaderHook(tag),
onBeforeRowHeaderRender: (args) => {
const practitioner = practitionerOf(args.row.id)
if (practitioner === null) {
args.row.cssClass = 'ss-rh--group'
return
}
args.row.html =
`<span class="flex w-full min-w-0 items-center justify-between gap-2">` +
`<span class="truncate font-medium">${escape(args.row.name)}</span>` +
`<span class="shrink-0 text-[11px] text-ink-3">${escape(text.rooms[practitioner.room])}</span></span>`
},
// Breaks, meetings and cleaning become disabled cells: the library refuses drops on them.
onBeforeCellRender: (args) => {
const practitioner = practitionerOf(args.cell.resource)
if (practitioner === null) return
const cellStart = civil(args.cell.start.ticks)
const block = blockAt(BLOCKS, practitioner.id, cellStart, civil(args.cell.end.ticks))
if (block === null) return
args.cell.properties.disabled = true
if (cellStart === block.start) {
// The engine clips cells with an inline style; the important utility lets the label of a
// block's first cell run into the next one at the 15-minute scale.
args.cell.properties.cssClass = 'overflow-visible!'
args.cell.properties.html = `<span class="relative z-[1] block whitespace-nowrap px-1.5 pt-1 text-[10.5px] font-semibold text-ink-3">${escape(text.blocks[block.kind])}</span>`
}
},
onBeforeEventRender: (args) => {
const fields = readAppointmentFields(args.data)
if (fields === null) return
const classes = ['ss-ev', toneClass(TREATMENT_TONES[fields.treatment])]
if (fields.status === 'unconfirmed') classes.push('ss-ev--ghost')
args.data.cssClass = classes.join(' ')
},
// React content inside each bar: code, status icon and treatment, sized to the bar.
renderEvent: ({ e, width }) => {
const fields = readAppointmentFields(e.data)
if (fields === null) return null
return (
<AppointmentContent
code={String(e.id())}
treatment={text.treatments[fields.treatment]}
status={fields.status}
statusLabel={text.statuses[fields.status]}
width={width}
/>
)
},
onEventMoving: (args) => {
const id = String(args.e.id())
const reason = refusalFor(
controlRef.current,
args.e.data,
id,
args.resource,
args.start,
args.end,
)
refusal.track(id, reason)
if (reason !== null) {
args.allowed = false
args.message = text.rules[reason]
}
},
// Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag.
onEventMove: (args) => {
const id = String(args.e.id())
const reason = refusalFor(
args.control,
args.e.data,
id,
args.newResource,
args.newStart,
args.newEnd,
)
if (reason !== null) {
args.preventDefault()
refusal.clear()
onRefused(id, reason)
}
},
onEventResizing: (args) => {
const id = String(args.e.id())
const resource = args.e.resource()
const reason =
resource === undefined
? null
: refusalFor(controlRef.current, args.e.data, id, resource, args.start, args.end)
refusal.track(id, reason)
if (reason !== null) {
args.allowed = false
args.message = text.rules[reason]
}
},
onEventResize: (args) => {
const id = String(args.e.id())
const resource = args.e.resource()
const reason =
resource === undefined
? null
: refusalFor(args.control, args.e.data, id, resource, args.newStart, args.newEnd)
if (reason !== null) {
args.preventDefault()
refusal.clear()
onRefused(id, reason)
}
},
onEventMoved: (args) => {
refusal.clear()
onMoved(String(args.e.id()))
},
onEventResized: (args) => {
refusal.clear()
onResized(String(args.e.id()))
},
onEventClick: (args) => onSelect(String(args.e.id())),
onZoom: (args) => {
if (args.phase !== 'end') return
const level = ZOOM_IDS[args.level]
if (level !== undefined) onZoomLevel(level)
},
}
}, [
controlRef,
initialZoom,
onEventsChange,
onMoved,
onRefused,
onResized,
onSelect,
onZoomLevel,
refusal,
schedulerLocale,
tag,
text,
])
return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}
/** What a bar shows, by width: icon, code and treatment when there is room, colour alone when not. */
function AppointmentContent({
code,
treatment,
status,
statusLabel,
width,
}: {
code: string
treatment: string
status: Status
statusLabel: string
width: number
}) {
if (width > 0 && width < 40) return null
const StatusIcon = status === 'confirmed' ? CheckIcon : Clock3Icon
return (
<span className="flex min-w-0 flex-col gap-0.5 leading-tight">
<span className="flex min-w-0 items-center gap-1 font-mono text-[11px] font-semibold tracking-tight">
{width >= 80 ? <StatusIcon aria-label={statusLabel} className="size-3 shrink-0" /> : null}
<span className="truncate">{code}</span>
</span>
<span className="truncate text-[11.5px] font-medium opacity-80">{treatment}</span>
</span>
)
}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.