SaludForma (Negocio ficticio)
Citas de clínica de fisioterapia
Un paciente no puede venir a las 10:00. Encuentra el siguiente hueco que respete pausas y limpiezas.
- Escala temporal
- Demo con datos sintéticos
- minutos / horas
- Capacidades Pro
- Escala por minutosReglas de movimientoTiempo bloqueadoSlots de renderizado ReactPanel de detalle
Carga SuperScheduler Pro solo cuando lo pides.
La situación
Forma es una clínica ficticia con tres fisioterapeutas, un osteópata y una terapeuta de masaje deportivo. Las citas duran de 15 a 60 minutos, cada profesional trabaja en una sala y el día se interrumpe con pausas, una sesión clínica y la limpieza de las salas entre tratamientos.
Cuando un paciente llama para cambiar una cita, recepción tiene segundos para encontrar hueco. Una cuadrícula en papel o un calendario genérico dejan citar encima de una pausa o dar una sesión de osteopatía a un fisioterapeuta. La agenda tiene que rechazar esos huecos y decir por qué, también en voz alta para quien usa lector de pantalla. Solo aparecen códigos de cita y tipos de tratamiento: en esta demo no hay datos de pacientes.
Qué vas a hacerIntentarás colocar una cita en un hueco de limpieza, oirás por qué se rechaza, la llevarás al primer hueco libre después de la limpieza y ajustarás su duración en pasos de 15 minutos.
Dónde termina la librería y empieza tu aplicación
SuperScheduler aporta
- Escalas por minutos: celdas de 30 y 15 minutos con ajuste a la rejilla y noches ocultas.
- Celdas deshabilitadas que rechazan soltar citas, y vetos por fotograma (allowed, message) al arrastrar o redimensionar.
- Una tarjeta de arrastre propia y contenido React dentro de cada cita con super-scheduler/react-render.
- Prevención de solapes por fila y movimiento completo por teclado con anuncios.
- Eventos controlados mediante onEventsChange.
El código de este ejemplo decide
- Qué disciplinas pueden hacer cada tratamiento.
- Las pausas, la sesión clínica y la limpieza de salas, y a qué profesional afecta cada una.
- Una frase por cada motivo de rechazo, anunciada en una región en vivo cortés cuando no se puede soltar la cita.
- El panel de detalle, el tiempo citado por día, la misión 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: tamaños de hueco, celdas deshabilitadas a partir de los bloqueos, la tarjeta de arrastre, el contenido React de cada cita y los callbacks donde las reglas rechazan un hueco.
/**
* 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>
)
}Comparte este escenario
El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.