MobilitéFleetline (Entreprise fictive)
Planification d’une flotte de location
Une citadine est immobilisée le jour du départ. Confiez son contrat à une autre voiture, gardez le temps de préparation et voyez où la flotte manque.
- Échelle de temps
- Démo avec données fictives
- jours / semaines
- Fonctionnalités Pro
- Arborescence de ressourcesÉvénements sur plusieurs joursZoom continuMinimapPanneau de détail
SuperScheduler Pro n’est chargé que lorsque vous le demandez.
La situation
Fleetline est une agence de location fictive avec dix véhicules : trois citadines, deux breaks, deux électriques et trois utilitaires. Le client réserve une catégorie et des dates ; l’agence choisit le véhicule qui part, et chaque retour demande nettoyage et contrôle (et recharge, pour les électriques) avant le départ suivant.
Dans un tableur, cette préparation est invisible : le client suivant arrive alors que l’utilitaire est encore au nettoyage. Le planning doit garder les dates de chaque contrat, refuser une catégorie inférieure ou un utilitaire pour une citadine, montrer la préparation après chaque retour et signaler les semaines où la flotte est presque épuisée.
Ce que vous allez faireVous allez déplacer un contrat bloqué vers une voiture libre (ou un surclassement gratuit), prolonger une location d’utilitaire depuis son panneau de détail, puis trouver avec le zoom et la minimap la semaine où toute la flotte est sortie.
Où s’arrête la bibliothèque et où commence votre application
SuperScheduler fournit
- Les catégories et leurs véhicules en arborescence de ressources, avec des lignes de groupe qui n’acceptent aucune location.
- Des barres sur plusieurs jours aux heures exactes de départ et de retour, un déplacement vertical seulement (moveHDisabled) et le refus des chevauchements par véhicule.
- Des zones ancrées dans le temps à l’intérieur de chaque barre pour la préparation après le retour.
- Un zoom jours et semaines avec transition animée et Ctrl/⌘ + molette, et une minimap dont la fenêtre fait défiler et zoomer le planning.
- Les notifications de vue et de zoom via super-scheduler/hooks, les déplacements au clavier et les annonces.
Le code de cet exemple décide
- Quel véhicule peut servir quel contrat : la même catégorie, ou un surclassement gratuit de la citadine au break.
- Le temps de préparation par catégorie, et le contrôle qui le garde libre avant le départ suivant.
- La série de charge de la flotte que dessine la minimap, et ses seuils orange et rouge.
- Les prolongations depuis le panneau de détail, et tous les libellés de cette page.
Le code de cette démo
Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.
L’intégration : l’arborescence des catégories, le zoom jours et semaines, la zone de préparation dans onBeforeEventRender, les contrôles au déplacement, puis la minimap et l’abonnement à la vue créés une fois le contrôle monté.
/**
* 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>
)
}Partager ce scénario
Le lien ouvre cet exemple avec la vue actuelle (échelle et options). Il ne contient jamais vos modifications ni de données personnelles.