MovilidadFleetline (Negocio ficticio)
Planificación de una flota de alquiler
Un compacto queda inmovilizado el día de la recogida. Pasa su alquiler a otro coche, respeta la limpieza y mira cuándo se queda sin flota la oficina.
- Escala temporal
- Demo con datos sintéticos
- días / semanas
- Capacidades Pro
- Árbol de recursosEventos de varios díasZoom continuoMinimapaPanel de detalle
Carga SuperScheduler Pro solo cuando lo pides.
La situación
Fleetline es una oficina de alquiler ficticia con diez vehículos: tres compactos, dos familiares, dos eléctricos y tres furgonetas. El cliente reserva una categoría y unas fechas; la oficina decide qué vehículo sale, y cada devolución necesita limpieza y revisión (y carga, en los eléctricos) antes de la siguiente recogida.
En una hoja de cálculo esa preparación no se ve: el siguiente cliente llega mientras la furgoneta todavía se está limpiando. El cuadrante tiene que mantener fijas las fechas de cada alquiler, rechazar una categoría inferior o una furgoneta para una reserva de compacto, mostrar la preparación tras cada devolución y avisar de las semanas en que la flota está casi agotada.
Qué vas a hacerMoverás un alquiler varado a un coche libre (o a una mejora gratuita), ampliarás un alquiler de furgoneta desde su panel de detalle y usarás el zoom y el minimapa para encontrar la semana en que sale toda la flota.
Dónde termina la librería y empieza tu aplicación
SuperScheduler aporta
- Categorías con sus vehículos como árbol de recursos, con filas de grupo que no admiten alquileres.
- Barras de varios días a la hora exacta de recogida y devolución, movimiento solo vertical (moveHDisabled) y rechazo de solapes por vehículo.
- Áreas ancladas al tiempo dentro de cada barra para la preparación tras la devolución.
- Zoom de días y semanas con cambio animado y Ctrl/⌘ + rueda, y un minimapa cuya ventana desplaza y amplía el cuadrante.
- Avisos de vista y de zoom con super-scheduler/hooks, movimientos con teclado y anuncios.
El código de este ejemplo decide
- Qué vehículo puede atender cada reserva: la misma categoría o una mejora gratuita de compacto a familiar.
- El tiempo de preparación por categoría y la comprobación que lo mantiene libre de la siguiente recogida.
- La serie de carga de la flota que dibuja el minimapa y sus umbrales ámbar y rojo.
- Las ampliaciones desde el panel de detalle 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: el árbol de categorías, el zoom de días y semanas, el área de preparación en onBeforeEventRender, las comprobaciones al mover, y el minimapa y la suscripción a la vista creados cuando el control ya está montado.
/**
* Fleetline rental board: vehicles grouped by category over seven weeks, with a fleet-load minimap.
*
* Library: resource tree, multi-day bars at exact times, vertical-only moves, overlap refusal with
* conflict highlight, day/week zoom (buttons, Ctrl/⌘+wheel, the minimap's edges), time-anchored
* areas for the preparation tail, the minimap widget and viewport subscriptions.
* Application: which vehicle may serve which booking, how long preparation takes, which rentals
* clash and what the minimap measures. Those rules live in ./rules.
*/
import { useEffect, useMemo, useRef, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { subscribeScheduler } from 'super-scheduler/hooks'
import { createMinimap, type Minimap } from 'super-scheduler/minimap'
import { format, plural } from '~/i18n/format'
import { DAY, formatCivil, type CivilDateTime } from '../../scheduler/civil'
import { toneClass } from '../../scheduler/tones'
import { createSpanHeaderHook } from '../kit/span-header-hook'
import {
civilOf,
isVehicleId,
itemTone,
readFleetFields,
toResources,
type FleetEvent,
} from './adapter'
import { PERIOD, type FleetItem } from './model'
import { assignment, clashes, fleetLoad, rentalDays } from './rules'
import type { FleetlineText, ZoomId } from './text'
import './fleet.css'
export interface FleetlineSchedulerProps {
readonly events: FleetEvent[]
/** The same data read back as the branch's model, for the rules and the minimap. */
readonly items: readonly FleetItem[]
readonly text: FleetlineText
/** 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 controlRef: RefObject<SuperScheduler.Scheduler | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onSelect: (id: string) => void
readonly onMoved: (id: 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
/** The visible span, published once scrolling or zooming settles. */
readonly onViewport: (start: CivilDateTime, end: CivilDateTime) => void
}
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
{
id: 'days',
properties: {
scale: 'Day',
cellWidth: 60,
timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
},
},
{
id: 'weeks',
properties: {
scale: 'Week',
cellWidth: 150,
timeHeaders: [{ groupBy: 'Month' }, { groupBy: 'Week' }],
},
},
]
const ZOOM_IDS: readonly ZoomId[] = ['days', 'weeks']
/** Minimap colours: amber from 75 % of the fleet busy, red from 90 %. */
const loadTone = (value: number) => (value >= 0.9 ? 'danger' : value >= 0.75 ? 'warn' : 'base')
export function FleetlineScheduler(props: FleetlineSchedulerProps) {
const { events, items, text, tag, schedulerLocale, initialZoom, controlRef } = props
const { onEventsChange, onSelect, onMoved, onRefused, onZoomLevel, onViewport } = props
const resources = useMemo(() => toResources(text), [text])
const clashing = useMemo(() => new Set(clashes(items)), [items])
// Day cells under week groups, or week cells under month groups (this period has no month shorter than a week).
const headerHook = useMemo(
() =>
createSpanHeaderHook(tag, (span, level) =>
level === 'group' ? (span > 7 * DAY ? 'month' : 'week') : span > DAY ? 'week' : 'day',
),
[tag],
)
const config = useMemo<SchedulerProps>(() => {
const refusal = (
data: object,
resource: SuperScheduler.ResourceId,
conflicts: number,
): string | null => {
const fields = readFleetFields(data)
if (fields === null) return null
if (fields.kind === 'service') return text.rules.fixed
if (!isVehicleId(resource)) return text.rules.categoryRow
if (assignment(resource, fields.booked) === null)
return text.rules.wrongCategory[fields.booked]
return conflicts > 0 ? text.rules.busy : null
}
return {
locale: schedulerLocale,
startDate: PERIOD.start,
days: PERIOD.days,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// The branch plans Monday-to-Sunday weeks, whatever the page language.
weekStarts: 1,
height: '100%',
treeEnabled: true,
// Category rows group vehicles; nothing is rented to a category row.
treePreventParentUsage: true,
rowHeaderWidth: 176,
rowHeaderWidthAutoFit: false,
eventHeight: 22,
rowMarginTop: 3,
rowMarginBottom: 3,
// Pickups and returns keep their hour: bars are drawn at exact times, not whole days.
useEventBoxes: 'Never',
allowEventOverlap: false,
conflictHighlight: 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 (!isVehicleId(id)) {
args.row.cssClass = 'ss-rh--group'
return
}
const plate = SuperScheduler.Util.escapeHtml(args.row.name)
const spec = SuperScheduler.Util.escapeHtml(text.specs[id])
args.row.html = `<span class="fl-rh"><span class="fl-rh__plate">${plate}</span><span class="fl-rh__spec">${spec}</span></span>`
},
onBeforeEventRender: (args) => {
const fields = readFleetFields(args.data)
if (fields === null) return
const classes = ['ss-ev', 'fl-ev', toneClass(itemTone(fields))]
if (fields.kind === 'service') {
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(text.services[fields.reason])}</span></span>`
classes.push('ss-ev--hatched')
} else {
const resource = args.data.resource
const upgraded =
resource !== undefined &&
isVehicleId(resource) &&
assignment(resource, fields.booked) === 'upgrade'
const days = plural(
text.days,
rentalDays({ pickup: civilOf(args.data.start), return: fields.returnAt }),
tag,
)
const badge = upgraded
? `<span class="ss-evt__badge">${SuperScheduler.Util.escapeHtml(text.upgrade)}</span>`
: ''
args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${SuperScheduler.Util.escapeHtml(fields.code)}</span><span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(days)}</span>${badge}</span>`
// The preparation after the return, drawn as a hatched tail anchored to its times.
args.data.areas = [
{
start: fields.returnAt,
end: args.data.end,
top: 0,
bottom: 0,
cssClass: 'fl-prep',
action: 'None',
},
]
if (clashing.has(String(args.data.id))) classes.push('fl-ev--clash')
}
args.data.cssClass = classes.join(' ')
},
// Only the row can change (moveHDisabled): check the category; the library checks overlaps.
onEventMoving: (args) => {
const message = refusal(args.e.data, args.resource, args.conflicts.length)
if (message !== null && message !== text.rules.busy) {
args.allowed = false
args.message = message
} else if (message !== null) {
args.message = message
}
onRefused(message)
},
// Final check before the commit (also covers keyboard moves).
onEventMove: (args) => {
const message = refusal(args.e.data, args.newResource, 0)
if (message !== null) {
args.preventDefault()
onRefused(message)
}
},
onEventMoved: (args) => onMoved(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)
},
}
}, [
clashing,
controlRef,
headerHook,
initialZoom,
onEventsChange,
onMoved,
onRefused,
onSelect,
onZoomLevel,
schedulerLocale,
tag,
text,
])
// The minimap and the viewport subscription need the mounted control; both go with it.
const minimapHost = useRef<HTMLDivElement>(null)
const minimap = useRef<Minimap | null>(null)
const latestItems = useRef(items)
useEffect(() => {
latestItems.current = items
minimap.current?.refresh()
}, [items])
useEffect(() => {
const control = controlRef.current
const host = minimapHost.current
if (control === null || host === null) return
const date = (value: SuperScheduler.Date) =>
formatCivil(value.getTime(), tag, { day: 'numeric', month: 'short' })
const widget = createMinimap(control, host, {
height: 30,
peak: 'absolute',
max: 1,
// One value per bucket: the share of the fleet that is out, in preparation or in the workshop.
series: (range) => {
const values = new Float32Array(range.buckets)
const start = range.start.getTime()
for (let index = 0; index < values.length; index++) {
const from = start + index * range.bucketMs
values[index] = fleetLoad(latestItems.current, from, from + range.bucketMs)
}
return values
},
tone: loadTone,
marks: { today: false, months: true, past: false },
labels: {
label: text.minimap.label,
zoom: text.minimap.zoom,
valueText: (start, end) => format(text.minimap.range, { from: date(start), to: date(end) }),
},
})
minimap.current = widget
const unsubscribe = subscribeScheduler(control, 'viewport', (state) =>
onViewport(civilOf(state.start), civilOf(state.end)),
)
return () => {
unsubscribe()
widget.dispose()
minimap.current = null
}
}, [controlRef, onViewport, tag, text])
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 [&>div]:h-full">
<SuperSchedulerComponent {...config} resources={resources} events={events} />
</div>
<div
ref={minimapHost}
className="fl-minimap shrink-0 border-t border-line bg-paper-2/60 px-2 py-1.5"
/>
</div>
)
}Comparte este escenario
El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.