FormationMorrow (Entreprise fictive)
Planification de salles de formation
Les inscriptions dépassent la salle. Sélectionnez les deux sessions, voyez ce qui est libre pour les deux, déplacez-les ensemble et gardez la vue.
- Échelle de temps
- Démo avec données fictives
- heures / semaines
- Fonctionnalités Pro
- FiltresColonnes de ligneMultisélectionVues enregistrées
SuperScheduler Pro n’est chargé que lorsque vous le demandez.
La situation
Morrow est un centre de formation d’entreprise fictif avec quatre salles, deux ateliers et quatre formateurs. Les cours ont lieu par demi-journées sur deux semaines ; chaque session demande une salle avec le bon équipement et assez de places, et un formateur qui n’est ni en cours ailleurs ni absent.
Quand des inscriptions tardives dépassent la capacité d’une salle, la coordination cherche une salle libre à chaque session, adaptée au cours, et qui garde les sessions ensemble. Le vérifier à l’œil sur dix jours est lent et source d’erreurs : le planning doit répondre à partir d’une sélection et garder la disposition habituelle de chacun à un clic.
Ce que vous allez faireVous allez sélectionner les deux sessions d’un cours, voir les salles et formateurs qui partagent leur disponibilité, les déplacer ensemble dans la salle qui convient, puis enregistrer la vue pour la rouvrir plus tard.
Où s’arrête la bibliothèque et où commence votre application
SuperScheduler fournit
- Des lignes groupées avec trois colonnes d’en-tête : nom, places et heures réservées.
- La multisélection au clic et au Ctrl/⌘+clic, conservée par le contrôle (multiselect.events()), et le glisser avec refus des chevauchements.
- Le filtrage des lignes avec control.rows.filter() et onRowFilter, et les classes de cellule avec onBeforeCellRender.
- Des grilles à l’heure et à la demi-journée sans nuits ni week-ends, la navigation au clavier et les annonces.
- getViewState et applyViewState de super-scheduler/views : zoom, défilement et groupes repliés en JSON.
Le code de cet exemple décide
- Des règles d’exemple entre ressources : équipement, places et disponibilité du formateur pour chaque session.
- La disponibilité partagée : les salles et formateurs libres et adaptés à toutes les sessions sélectionnées.
- Le déplacement des autres sessions sélectionnées avec celle que l’on fait glisser : l’application les décale avec control.events.update() après le dépôt.
- Le filtre de places, le filtre « Libres pour la sélection » et le temps d’enseignement des formateurs, déduit des sessions.
- L’endroit où les vues sont conservées (ici, en mémoire pendant la visite), leurs noms 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 : colonnes d’en-tête, multisélection, les contrôles au déplacement et les sessions qui suivent celle que l’on fait glisser, la mise en évidence de la disponibilité dans onBeforeCellRender et le filtre passé à rows.filter().
/**
* 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,
)
}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.