Saltar al contenido
SuperScheduler

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
Forma
FisioterapiaAlba R.Sala 1Marc D.Sala 2Nora S.GimnasioTerapia manualTeo V.Sala 3Iris M.Sala 4F-2311 Primera valoraciónF-2318 Rehabilitación de rodillaF-2320 Movilidad de hombroF-2324 Tratamiento lumbarF-2330 Rehabilitación de rodillaF-2333 Revisión de evoluciónF-2312 Tratamiento lumbarF-2315 Rehabilitación de rodillaF-2319 Movilidad de hombroF-2322 Primera valoraciónF-2331 Tratamiento lumbarF-2335 Revisión de evoluciónF-2313 Ejercicio terapéuticoF-2317 Ejercicio terapéuticoF-2323 Ejercicio terapéuticoF-2332 Ejercicio terapéuticoF-2314 OsteopatíaF-2316 OsteopatíaF-2321 Primera valoraciónF-2326 Tratamiento lumbarF-2334 OsteopatíaF-2310 Masaje deportivoF-2325 Masaje deportivoF-2327 Masaje deportivoF-2336 Masaje deportivojueves, 15 de octubre08:0008:3009:0009:3010:0010:3011:0011:3012:0012:3013:0013:3014:0014:3015:0015:3016:0016:3017:0017:30

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.

scheduler.tsxtsx
/**
 * 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>
  )
}
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 relacionadoReservas de pistas en un club deportivoA 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.Leer el caso de negocioClínicas y salud