Saltar al contenido
SuperScheduler

FormaciónMorrow (Negocio ficticio)

Planificación de aulas de formación

La matrícula superó el aula. Selecciona ambas sesiones, mira qué está libre para las dos, muévelas juntas y conserva la vista.

Escala temporal
Demo con datos sintéticos
horas / semanas
Capacidades Pro
FiltrosColumnas de filaMultiselecciónVistas guardadas
Morrow
AulasAtlas24 · 32 hBirch16 · 16 hCedar12 · 12 hDelta30 · 12 hTalleresForge10 · 32 hLab14 · 24 hFormadoresInés40 hOwen32 hPriya24 hTomás32 hNegociaciónLiderazgoNegociaciónLiderazgoAcogidaPrimeros auxiliosPrimeros auxiliosHojas de cálculoPythonPythonAlfabetización de datosAlfabetización de datosNegociaciónSoldaduraCarretillas elevadorasDe permisoFuera del centromartes, 10 de noviembremiércoles, 11 de noviembrejueves, 12 de noviembreviernes, 13 de noviembrelunes, 16 de noviembre08:0012:0008:0012:0008:0012:0008:0012:0008:0012:00

Carga SuperScheduler Pro solo cuando lo pides.

La situación

Morrow es un centro de formación de empresa ficticio con cuatro aulas, dos talleres y cuatro formadores. Los cursos se imparten en medias jornadas durante dos semanas; cada sesión necesita un aula con el equipamiento adecuado y plazas suficientes, y un formador que no esté dando otra clase ni ausente.

Cuando las matrículas de última hora superan el aforo de un aula, coordinación necesita un aula libre en todas las sesiones, adecuada al curso y que mantenga las sesiones juntas. Comprobarlo a ojo en diez días es lento y fácil de fallar: el cuadrante tiene que responderlo a partir de una selección y dejar a un clic la disposición habitual de cada coordinador.

Qué vas a hacerSeleccionarás las dos sesiones de un curso, verás qué aulas y formadores comparten disponibilidad con ellas, las moverás juntas al aula que encaja y guardarás la vista para recuperarla después.

Dónde termina la librería y empieza tu aplicación

SuperScheduler aporta

  • Filas agrupadas con tres columnas en la cabecera: nombre, plazas y horas reservadas.
  • Multiselección con clic y Ctrl/⌘+clic que guarda el control (multiselect.events()), y arrastre con rechazo de solapes.
  • Filtrado de filas con control.rows.filter() y onRowFilter, y clases de celda con onBeforeCellRender.
  • Rejillas por horas y por medias jornadas sin noches ni fines de semana, navegación con teclado y anuncios.
  • getViewState y applyViewState de super-scheduler/views: zoom, desplazamiento y grupos plegados en JSON.

El código de este ejemplo decide

  • Reglas de ejemplo entre recursos: equipamiento, plazas y disponibilidad del formador en cada sesión.
  • Disponibilidad compartida: las aulas y formadores libres y adecuados para todas las sesiones seleccionadas.
  • Mover las demás sesiones seleccionadas con la arrastrada: la aplicación las desplaza con control.events.update() al soltar.
  • El filtro de plazas, el filtro «Libres para la selección» y las horas de clase de cada formador, derivadas de las sesiones.
  • Dónde se guardan las vistas (aquí, en memoria durante la visita), sus nombres 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: columnas de cabecera, multiselección, las comprobaciones al mover y las sesiones que acompañan a la arrastrada, el resaltado de disponibilidad en onBeforeCellRender y el filtro que recibe rows.filter().

scheduler.tsxtsx
/**
 * Morrow room board: classrooms, workshops and instructors over two teaching weeks.
 *
 * Library: grouped rows with row-header columns, hour and half-day grids with nights and weekends
 * hidden, multi-selection (Ctrl/⌘+click), row filtering through onRowFilter, cell styling through
 * onBeforeCellRender, keyboard and announcements.
 * Application (example logic): which room suits which course, when an instructor is free, and
 * which rooms and instructors share availability with the selected sessions, and moving the other
 * selected sessions along with a dragged one. See ./rules.
 */
import { useEffect, useMemo, useSyncExternalStore, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { HOUR, parseCivil, type CivilDateTime } from '../../scheduler/civil'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
  COURSE_TONES,
  civilOf,
  hoursLabel,
  isInstructorId,
  isRoomId,
  readCentreFields,
  toResources,
  type CentreEvent,
} from './adapter'
import {
  COURSE_KIT,
  INITIAL_VIEW_START,
  PERIOD,
  TEACHING_HOURS,
  type CentreItem,
  type RoomId,
  type Session,
} from './model'
import {
  companionLandings,
  instructorIssue,
  roomFree,
  roomIssue,
  roomOf,
  sessionIssues,
  sharedAvailability,
  withinTeachingHours,
} from './rules'
import type { MorrowText, SeatFilter, ZoomId } from './text'
import type { MorrowFilters } from './views'
import './morrow.css'

/** The toolbar's filters, handed to `control.rows.filter()` and read back in onRowFilter. */
export class RowFilter {
  constructor(
    readonly minSeats: number,
    /** Rows to keep (shared availability plus the rows of the selection), or null for all. */
    readonly only: ReadonlySet<string> | null,
  ) {}
}

export interface MorrowSchedulerProps {
  readonly events: CentreEvent[]
  /** The same data read back as the centre's model, for the rules. */
  readonly items: readonly CentreItem[]
  /** Ids of the selected sessions (mirrors the control's multiselect). */
  readonly selected: readonly string[]
  readonly filters: MorrowFilters
  readonly text: MorrowText
  /** 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 or saved views. */
  readonly initialZoom: ZoomId
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelectionChange: (ids: readonly string[]) => void
  readonly onMoved: (ids: readonly 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: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 48,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
    },
  },
  // Morning and afternoon blocks: 8:00-12:00 and 12:00-16:00.
  {
    id: 'week',
    properties: {
      scale: 'CellDuration',
      cellDuration: 240,
      cellWidth: 120,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['hours', 'week']

const MIN_SEATS = { all: 0, '16': 16, '24': 24 } as const satisfies Record<SeatFilter, number>

export function MorrowScheduler(props: MorrowSchedulerProps) {
  const { events, items, selected, filters, text, tag, schedulerLocale, initialZoom, controlRef } =
    props
  const { onEventsChange, onSelectionChange, onMoved, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])
  const issues = useMemo(() => sessionIssues(items), [items])

  // Shared availability of the selection, and the time spans the grid highlights for it.
  const selection = useMemo(
    () =>
      items.filter(
        (item): item is Session => item.kind === 'session' && selected.includes(item.id),
      ),
    [items, selected],
  )
  const shared = useMemo(() => sharedAvailability(selection, items), [selection, items])
  const fitRows = useMemo(() => new Set<string>([...shared.rooms, ...shared.instructors]), [shared])
  const selectedSpans = useMemo(
    () => selection.map((session) => [parseCivil(session.start), parseCivil(session.end)] as const),
    [selection],
  )
  // Instructor rows show when each instructor teaches, derived from the sessions on room rows.
  const teaching = useMemo(() => {
    const spans = new Map<string, (readonly [number, number])[]>()
    for (const item of items) {
      if (item.kind !== 'session') continue
      spans.set(item.instructor, [
        ...(spans.get(item.instructor) ?? []),
        [parseCivil(item.start), parseCivil(item.end)] as const,
      ])
    }
    return spans
  }, [items])

  const compact = useNarrowScreen()

  const headerHook = useMemo(
    () =>
      createSpanHeaderHook(tag, (span, level) =>
        level === 'group' ? 'day' : span >= 4 * HOUR ? 'hours-4' : 'hour',
      ),
    [tag],
  )

  const config = useMemo<SchedulerProps>(() => {
    const sessions = new Map(
      items.flatMap((item) => (item.kind === 'session' ? [[item.id, item] as const] : [])),
    )
    // The selected sessions that follow a dragged one (only when the dragged one is selected too).
    const companionsOf = (id: string, room: RoomId, start: string) => {
      const dragged = sessions.get(id)
      return dragged !== undefined && selection.some((session) => session.id === id)
        ? companionLandings(dragged, room, start, selection)
        : []
    }
    // Why the centre refuses this landing, for the dragged session and each companion, or null.
    const refusal = (
      data: object,
      resource: SuperScheduler.ResourceId,
      start: CivilDateTime,
      end: CivilDateTime,
    ): string | null => {
      const fields = readCentreFields(data)
      if (fields === null) return null
      if (fields.kind === 'away') return text.rules.fixed
      if (!isRoomId(resource)) return text.rules.notRoom
      const id = 'id' in data ? String(data.id) : ''
      const companions = companionsOf(id, resource, start)
      const moving = new Set([id, ...companions.map((landing) => landing.session.id)])
      const landings = [
        { ...fields, room: resource, start, end, companion: false },
        ...companions.map((landing) => ({ ...landing.session, ...landing, companion: true })),
      ]
      for (const landing of landings) {
        if (!withinTeachingHours(landing)) return text.rules.hours
        const room = roomIssue(landing.course, landing.attendees, landing.room)
        const kit = COURSE_KIT[landing.course]
        if (room === 'kit' && kit !== null) return text.rules.kit[kit]
        if (room === 'seats') return text.rules.seats
        // The library checks overlaps for the dragged session only; companions are checked here.
        if (landing.companion && !roomFree(landing.room, landing, items, moving))
          return text.rules.roomTaken
        const instructor = instructorIssue(landing.instructor, landing, items, moving)
        if (instructor !== null) return text.rules[instructor]
      }
      return null
    }

    return {
      locale: schedulerLocale,
      startDate: PERIOD.start,
      days: PERIOD.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Courses run 8:00 to 16:00 on weekdays: nights and weekends are hidden.
      businessBeginsHour: TEACHING_HOURS.begin,
      businessEndsHour: TEACHING_HOURS.end,
      showNonBusiness: false,
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      // On a phone only the name column stays; seats and hours are in the details panel.
      rowHeaderColumns: [
        { title: text.columns.resource, width: 124 },
        { title: text.columns.seats, width: 72, hidden: compact },
        { title: text.columns.booked, width: 72, hidden: compact },
      ],
      rowHeaderWidthAutoFit: false,
      eventHeight: 26,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      useEventBoxes: 'Never',
      allowEventOverlap: false,
      // Click selects; Ctrl/⌘+click adds to the selection (the control keeps it in multiselect).
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      dragCard: { duration: false },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        const id = args.row.id
        if (!isRoomId(id) && !isInstructorId(id)) return
        const [name, , booked] = args.row.columns
        const label = SuperScheduler.Util.escapeHtml(
          isRoomId(id) ? text.rooms[id] : text.instructors[id],
        )
        const badge = fitRows.has(id)
          ? `<span class="mr-badge">${SuperScheduler.Util.escapeHtml(text.freeBadge)}</span>`
          : ''
        if (name !== undefined)
          name.html = `<span class="mr-rh"><span class="mr-rh__name">${label}</span>${badge}</span>`
        if (booked !== undefined)
          booked.html = `<span class="mr-num">${SuperScheduler.Util.escapeHtml(hoursLabel(id, items, text, tag))}</span>`
        args.row.cssClass = fitRows.has(id) ? 'mr-rh--fit' : ''
      },

      onBeforeCellRender: (args) => {
        const { cell } = args
        if (cell.isParent) return
        const from = cell.start.getTime()
        const to = cell.end.getTime()
        const hit = (spans: readonly (readonly [number, number])[] | undefined) =>
          spans?.some(([start, end]) => start < to && from < end) === true
        const classes: string[] = []
        if (hit(teaching.get(String(cell.resource)))) classes.push('mr-cell--teaching')
        if (fitRows.has(String(cell.resource)) && hit(selectedSpans)) classes.push('mr-cell--fit')
        cell.properties.cssClass = classes.join(' ')
      },

      onBeforeEventRender: (args) => {
        const fields = readCentreFields(args.data)
        if (fields === null) return
        if (fields.kind === 'away') {
          args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(text.away[fields.reason])}</span></span>`
          args.data.cssClass = `ss-ev ${toneClass('blocked')} ss-ev--hatched`
          return
        }
        const title = SuperScheduler.Util.escapeHtml(text.courses[fields.course])
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span><span class="ss-evt__meta">${fields.attendees}</span></span>`
        const classes = ['ss-ev', 'mr-ev', toneClass(COURSE_TONES[fields.course])]
        if (issues.has(String(args.data.id))) classes.push('mr-ev--issue')
        // The control tracks the selection; the app draws it, since several sessions can be selected.
        if (selected.includes(String(args.data.id))) classes.push('mr-ev--selected')
        args.data.cssClass = classes.join(' ')
      },

      // Room suitability, instructor availability and teaching hours for every session that moves.
      onEventMoving: (args) => {
        const message = refusal(args.e.data, args.resource, civilOf(args.start), civilOf(args.end))
        if (message !== null) {
          args.allowed = false
          args.message = message
        }
        onRefused(message)
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const message = refusal(
          args.e.data,
          args.newResource,
          civilOf(args.newStart),
          civilOf(args.newEnd),
        )
        if (message !== null) {
          args.preventDefault()
          onRefused(message)
        }
      },

      // The library committed the dragged session; the other selected ones follow it through the
      // control's API, so one onEventsChange hands all of them back to React state.
      onEventMoved: (args) => {
        const id = String(args.e.id())
        const moved = [id]
        if (isRoomId(args.newResource)) {
          for (const landing of companionsOf(id, args.newResource, civilOf(args.newStart))) {
            const companion = args.control.events.find(landing.session.id)
            if (companion === null) continue
            args.control.events.update({
              ...companion.data,
              resource: landing.room,
              start: landing.start,
              end: landing.end,
            })
            moved.push(landing.session.id)
          }
        }
        onMoved(moved)
      },

      onEventSelected: () => {
        const control = controlRef.current
        if (control !== null)
          onSelectionChange(control.multiselect.events().map((event) => String(event.id())))
      },

      onRowFilter: (args) => {
        const filter = args.filterParam
        if (!(filter instanceof RowFilter)) return
        const id = args.row.id
        if (isRoomId(id))
          args.visible =
            roomOf(id).seats >= filter.minSeats && (filter.only === null || filter.only.has(id))
        else if (isInstructorId(id)) args.visible = filter.only === null || filter.only.has(id)
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    compact,
    controlRef,
    fitRows,
    headerHook,
    initialZoom,
    issues,
    items,
    onEventsChange,
    onMoved,
    onRefused,
    onSelectionChange,
    onZoomLevel,
    schedulerLocale,
    selected,
    selectedSpans,
    selection,
    tag,
    teaching,
    text,
  ])

  // Filters go through the library's row filtering; the rows of the selection always stay.
  const rowFilter = useMemo(() => {
    const keep =
      filters.freeOnly && selection.length > 0
        ? new Set([
            ...fitRows,
            ...selection.flatMap((session) => [session.room, session.instructor]),
          ])
        : null
    return new RowFilter(MIN_SEATS[filters.seats], keep)
  }, [filters, selection, fitRows])
  useEffect(() => {
    controlRef.current?.rows.filter(rowFilter)
  }, [controlRef, rowFilter])

  // Imperative and once: the board opens on Tuesday, the first Data literacy sitting.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

  return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}

const NARROW = '(max-width: 639px)'

/** True on phone-sized screens (the stage is then too narrow for three header columns). */
function useNarrowScreen(): boolean {
  return useSyncExternalStore(
    (notify) => {
      const query = window.matchMedia(NARROW)
      query.addEventListener('change', notify)
      return () => query.removeEventListener('change', notify)
    },
    () => window.matchMedia(NARROW).matches,
    () => false,
  )
}
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 relacionadoReserva de instrumentos de laboratorioReserva un instrumento y la calibración viene incluida. Saca una sesión del servicio técnico y alarga tu medición.Leer el caso de negocioSalas y equipos compartidos