Saltar al contenido
SuperScheduler

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
Fleetline
CompactoFL 204ManualFL 206AutomáticoFL 208ManualFamiliarFL 3117 plazasFL 3155 plazasEléctricoFL E21420 kmFL E24380 kmFurgonetaFL V029 m³FL V0512 m³FL V0712 m³R-1088R-1101R-1120R-1097R-1126R-1093Reparación de parabrisasR-1131R-1090R-1110R-1139R-1104R-1128R-1095R-1115Cambio de neumáticosR-1118R-1099R-1123R-1107R-1112R-1136Revisión anualR-1114R-111612 oct19 oct26 oct2 novlun 12mar 13mié 14jue 15vie 16sáb 17dom 18lun 19mar 20mié 21jue 22vie 23sáb 24dom 25lun 26mar 27mié 28jue 29vie 30sáb 31dom 1lun 2

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.

scheduler.tsxtsx
/**
 * 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>
  )
}
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 relacionadoPlanning de habitaciones de hotelGotea la ducha de la 104. Recoloca al siguiente cliente, bloquea la habitación para el fontanero y localiza las noches que ya están llenas.Leer el caso de negocioAlojamiento y alquiler