LaboratorioBenchlab (Negocio ficticio)
Reserva de instrumentos de laboratorio
Reserva un instrumento y la calibración viene incluida. Saca una sesión del servicio técnico y alarga tu medición.
- Escala temporal
- Demo con datos sintéticos
- horas / días
- Capacidades Pro
- Slots de renderizado ReactTiempo bloqueadoReglas de movimientoPanel de detalle
Carga SuperScheduler Pro solo cuando lo pides.
La situación
Benchlab es un laboratorio compartido ficticio: dos microscopios, un LC-MS, un espectrofotómetro, termocicladores de qPCR, un secuenciador y bancadas que usan cuatro grupos de investigación. Casi todos los instrumentos necesitan tiempo antes de medir: el láser del confocal se calienta, el LC-MS se calibra durante una hora y el secuenciador ceba su celda de flujo.
Si la reserva solo registra la medición, la preparación invade el turno anterior y la visita del servicio técnico cae sobre una sesión que nadie movió. El tablero tiene que tratar la preparación como parte de la reserva, dejar fuera de alcance el tiempo no disponible y explicar con claridad por qué una petición no cabe. Las reglas son la política de reservas de este laboratorio, no un sistema de cumplimiento normativo.
Qué vas a hacerSacarás una sesión de una ventana de servicio técnico, reservarás dos horas de LC-MS con la calibración añadida automáticamente y alargarás esa medición sin que la calibración cambie.
Dónde termina la librería y empieza tu aplicación
SuperScheduler aporta
- Niveles de zoom por horas y por días con noches y fines de semana ocultos.
- Celdas deshabilitadas que rayan las horas no disponibles y rechazan soltar, fijadas en onBeforeCellRender.
- Áreas de evento ancladas en el tiempo, usadas para dibujar la preparación dentro de cada reserva.
- Selección de rangos arrastrando, arrastre y redimensionado sin solapes, mensajes de rechazo en la tarjeta de arrastre, deshacer/rehacer con eventos controlados y altas registradas.
- Cabeceras de fila (renderRowHeader) y barras de reserva (renderEvent) en React con super-scheduler/react-render.
El código de este ejemplo decide
- Cuánto se prepara cada instrumento y cómo una medición seleccionada se convierte en una reserva con la preparación delante.
- Qué cambios se permiten: solo entre instrumentos idénticos, nunca sobre servicio técnico o mantenimiento, mediciones de 30 minutos o más y la preparación fija al principio.
- Las ventanas de servicio técnico y mantenimiento, y las reservas con las que chocan.
- El panel de la reserva, la misión y todos los textos de esta página. Sin promesas normativas ni de auditoría.
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: niveles de zoom, celdas deshabilitadas para el tiempo no disponible, el área de preparación de cada reserva, reservar arrastrando y los callbacks donde las reglas rechazan un movimiento o un cambio de duración.
/**
* Benchlab instrument board: shared instruments and benches over one week, preparation included.
*
* Library: timeline with hidden nights, hour and day zoom levels, tree rows, disabled cells for
* unavailable time, time-anchored event areas for the preparation, drag-to-book range selection,
* drag/resize with overlap prevention, undo history, keyboard, and React content (row headers and
* booking bars) through `super-scheduler/react-render`.
* Application: how long each instrument prepares, where a booking may go, unavailable windows and
* what a new booking needs in front of its run. Those rules live in ./rules and run in the callbacks.
*/
import { useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { parseCivil } from '../../scheduler/civil'
import { usesTwelveHours } from '../../scheduler/header-format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
GROUP_TONES,
civilOf,
isInstrumentId,
readBookingFields,
toResources,
type BookingEvent,
} from './adapter'
import { INITIAL_VIEW_START, LAB_HOURS, WEEK, type Block, type InstrumentId } from './model'
import { blockAt, instrumentOf, moveProblem, type MoveProblem } from './rules'
import { BookingBar, InstrumentHeader } from './instrument-slots'
import type { BenchlabText, ZoomId } from './text'
import './lab.css'
export interface BenchlabSchedulerProps {
readonly events: BookingEvent[]
readonly blocks: readonly Block[]
/** Bookings that overlap unavailable time. */
readonly collisions: readonly string[]
readonly text: BenchlabText
/** 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 history: SchedulerHistory
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
/** A run dragged across free time: the application plans the booking around it. */
readonly onRequest: (instrument: InstrumentId, start: string, end: string) => void
readonly onSelect: (id: string) => void
readonly onMoved: (id: string) => void
readonly onResized: (id: string) => void
readonly onRefused: (message: string) => void
readonly onZoomLevel: (level: ZoomId) => void
}
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'hours',
properties: {
scale: 'Hour',
cellWidth: 56,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
},
},
{
id: 'days',
properties: {
scale: 'Day',
cellWidth: 180,
timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['hours', 'days']
export function BenchlabScheduler(props: BenchlabSchedulerProps) {
const {
events,
blocks,
collisions,
text,
tag,
schedulerLocale,
initialZoom,
history,
controlRef,
} = props
const { onEventsChange, onRequest, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props
const resources = useMemo(() => toResources(text), [text])
// Header labels follow the active level. An animated zoom renders the new level's headers before
// control.scale reports it, so the hook is rebuilt (headers repainted) once a zoom lands.
const [headerHook, setHeaderHook] = useState(() => createHeaderHook(tag))
// Narrower row headers on phones (no icons, smaller indent), chosen once on mount.
const compact = useMemo(() => window.matchMedia('(max-width: 639px)').matches, [])
const config = useMemo<SchedulerProps>(() => {
const colliding = new Set(collisions)
const pattern = usesTwelveHours(tag) ? 'h:mm tt' : 'HH:mm'
const refusal = (problem: MoveProblem) =>
problem === 'other-model'
? text.rules.otherModel
: problem === 'unavailable'
? text.rules.unavailable
: text.rules.tooShort
return {
locale: schedulerLocale,
startDate: WEEK.start,
days: WEEK.days,
weekStarts: 1,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// The lab is open 07:00–20:00: nights stay hidden on the hour level.
businessBeginsHour: LAB_HOURS.begin,
businessEndsHour: LAB_HOURS.end,
showNonBusiness: false,
useEventBoxes: 'Never',
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
rowHeaderWidth: compact ? 144 : 216,
rowHeaderWidthAutoFit: false,
treeIndent: compact ? 10 : 20,
eventHeight: 30,
rowMarginTop: 3,
rowMarginBottom: 3,
allowEventOverlap: false,
// Selecting free time (pointer drag or keyboard) asks for a booking; plain clicks do not.
timeRangeSelectedHandling: 'Enabled',
dragCard: {
duration: false,
dateFormat: pattern,
movingDateFormat: pattern,
markerFormat: pattern,
},
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
history,
controlRef,
onEventsChange,
onBeforeTimeHeaderRender: headerHook,
onBeforeRowHeaderRender: (args) => {
args.row.cssClass = args.row.data.children !== undefined ? 'ss-rh--group' : ''
},
// Unavailable time: hour cells inside a window are disabled (hatched, drops refused by the
// library); a day cell that only partly overlaps one is tinted.
onBeforeCellRender: (args) => {
const instrument = args.cell.resource
if (!isInstrumentId(instrument)) return
const start = args.cell.start.value
const end = args.cell.end.value
const block = blockAt(instrument, start, end, blocks)
if (block === undefined) return
if (
parseCivil(block.start) <= parseCivil(start) &&
parseCivil(end) <= parseCivil(block.end)
) {
args.cell.properties.disabled = true
return
}
args.cell.properties.cssClass = 'bl-unavailable'
args.cell.properties.html = `<span class="block truncate px-1.5 pt-1 text-[11px] text-ink-3">${SuperScheduler.Util.escapeHtml(text.blocks[block.kind])}</span>`
},
onBeforeEventRender: (args) => {
const fields = readBookingFields(args.data)
if (fields === null) return
// HTML fallback shown until the React bar (renderEvent) mounts.
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(fields.project)}</span></span>`
const classes = ['ss-ev', toneClass(GROUP_TONES[fields.group])]
if (colliding.has(String(args.data.id)))
classes.push('ss-ev--late', 'outline-2', 'outline-offset-1', 'outline-danger')
args.data.cssClass = classes.join(' ')
// The preparation is drawn by the library as an area anchored in time at the booking start.
args.data.areas =
fields.prepMinutes === 0
? []
: [
{
start: args.data.start,
end: args.data.start.addMinutes(fields.prepMinutes),
top: 0,
bottom: 0,
cssClass: 'bl-prep',
action: 'None',
},
]
},
// Identical instruments only, never across unavailable time; overlap is refused by the library.
onEventMoving: (args) => {
const fields = readBookingFields(args.e.data)
const from = args.e.resource()
if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.resource)) return
const problem = moveProblem(
{ instrument: from, prepMinutes: fields.prepMinutes },
args.resource,
args.start.value,
args.end.value,
blocks,
)
if (problem !== null) {
args.allowed = false
args.message = refusal(problem)
}
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const fields = readBookingFields(args.e.data)
const from = args.e.resource()
if (fields === null || !isInstrumentId(from) || !isInstrumentId(args.newResource)) return
const problem = moveProblem(
{ instrument: from, prepMinutes: fields.prepMinutes },
args.newResource,
args.newStart.value,
args.newEnd.value,
blocks,
)
if (problem !== null) {
args.preventDefault()
onRefused(refusal(problem))
}
},
// The preparation belongs to the instrument: only the end of a booking can be dragged.
onEventResizing: (args) => {
const fields = readBookingFields(args.e.data)
const instrument = args.e.resource()
if (fields === null || !isInstrumentId(instrument)) return
const problem =
args.what === 'start'
? null
: moveProblem(
{ instrument, prepMinutes: fields.prepMinutes },
instrument,
args.start.value,
args.end.value,
blocks,
)
if (args.what === 'start' || problem !== null) {
args.allowed = false
args.message = problem === null ? text.rules.startEdge : refusal(problem)
}
},
onEventResize: (args) => {
const fields = readBookingFields(args.e.data)
const instrument = args.e.resource()
if (fields === null || !isInstrumentId(instrument)) return
const problem = moveProblem(
{ instrument, prepMinutes: fields.prepMinutes },
instrument,
args.newStart.value,
args.newEnd.value,
blocks,
)
if (args.what === 'start' || problem !== null) {
args.preventDefault()
onRefused(problem === null ? text.rules.startEdge : refusal(problem))
}
},
onTimeRangeSelected: (args) => {
args.control.clearSelection()
if (args.origin === 'click' || !isInstrumentId(args.resource)) return
onRequest(args.resource, civilOf(args.start), civilOf(args.end))
},
onEventMoved: (args) => onMoved(String(args.e.id())),
onEventResized: (args) => onResized(String(args.e.id())),
onEventClick: (args) => onSelect(String(args.e.id())),
onZoom: (args) => {
if (args.phase !== 'end') return
setHeaderHook(() => createHeaderHook(tag))
const level = ZOOM_IDS[args.level]
if (level !== undefined) onZoomLevel(level)
},
}
}, [
history,
controlRef,
blocks,
collisions,
compact,
onEventsChange,
onMoved,
onRefused,
onRequest,
onResized,
onSelect,
onZoomLevel,
schedulerLocale,
headerHook,
tag,
text,
initialZoom,
])
// React row headers: instruments show how long they prepare before every run.
const renderRowHeader = useMemo<NonNullable<SchedulerProps['renderRowHeader']>>(
() =>
({ row }) => {
const id = row.id
if (!isInstrumentId(id)) return row.name
const instrument = instrumentOf(id)
const minutes = instrument.prep.minutes
return (
<InstrumentHeader
name={text.instruments[id]}
area={compact ? null : instrument.area}
prep={
minutes === 0
? text.noPrep
: format(text.prepChip, {
minutes: plural(text.minutes, minutes, tag),
kind: text.prepKinds[instrument.prep.kind],
})
}
/>
)
},
[text, tag, compact],
)
// React booking bars: project, group and sample count; a warning icon when the time is unavailable.
const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
() =>
({ e, data, width }) => {
const fields = readBookingFields(data)
if (fields === null) return e.text()
return (
<BookingBar
project={fields.project}
group={text.groups[fields.group]}
samples={plural(text.samples, fields.samples, tag)}
unavailable={collisions.includes(String(e.id()))}
compact={width < 176}
/>
)
},
[collisions, text, tag],
)
// Imperative and once: a scroll prop would be re-applied on every update.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
return (
<SuperSchedulerComponent
{...config}
resources={resources}
events={events}
renderRowHeader={renderRowHeader}
renderEvent={renderEvent}
/>
)
}Comparte este escenario
El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.