IndustrieForge (Entreprise fictive)
Ordonnancement des ordres de fabrication
La maintenance a été avancée. Déplacez l’ordre et gardez ses opérations dans le bon ordre.
- Échelle de temps
- Démo avec données fictives
- postes / heures
- Fonctionnalités Pro
- Arborescence de ressourcesDépendancesCréneaux bloquésMinimap
SuperScheduler Pro n’est chargé que lorsque vous le demandez.
La situation
Forge est un atelier fictif d’usinage à façon : deux fraiseuses CNC et un tour, deux cellules d’assemblage, une cabine de peinture et un laboratoire qualité, tenus par quatre équipes en trois postes. Chaque ordre suit une gamme (fraisage, tournage, assemblage, peinture, contrôle) et aucune opération ne peut commencer avant la fin de la précédente.
La maintenance n’attend pas le plan. Quand la révision de broche de la fraiseuse CNC 2 est avancée, le fraisage prévu dessus doit bouger, et ce qui le suit dans la gamme risque de commencer trop tôt. Le planning doit refuser les déplacements impossibles, montrer les opérations hors séquence et signaler où la semaine est déjà pleine.
Ce que vous allez faireVous sortirez un fraisage d’une fenêtre de maintenance, remettrez son tournage dans la séquence et utiliserez la bande de charge pour aller jusqu’à la maintenance suivante, jeudi.
Où s’arrête la bibliothèque et où commence votre application
SuperScheduler fournit
- Arborescence de lignes et de machines, glisser-déposer entre machines et prévention des chevauchements par machine.
- Cellules désactivées qui refusent les dépôts sur la maintenance, avec vetos et messages à chaque image.
- Liens fin-début entre opérations, stylés lien par lien.
- Échelles en heures et en postes avec zoom animé, déplacements au clavier et annonces.
- La minimap de super-scheduler/minimap et l’état du viewport de super-scheduler/hooks.
Le code de cet exemple décide
- Quel type de machine réalise chaque opération, et le calendrier de maintenance.
- La gamme de chaque ordre et le contrôle qui signale une opération commençant trop tôt.
- La série de charge : temps machine occupé sur temps disponible, maintenance déduite.
- Des durées fixes (pas de redimensionnement), le panneau de détail, la mission et tous les textes.
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 : échelles en heures et en postes, maintenance en cellules désactivées, liens de séquence, refus, et bande de charge avec sa série calculée.
/**
* Forge production board: production lines and their machines over one week of three shifts,
* with each work order's operations linked in sequence and a plant-load strip underneath.
*
* Library: tree rows, drag between machines, disabled cells, finish-to-start links, hour and
* shift scales, the minimap (super-scheduler/minimap) and viewport state (super-scheduler/hooks).
* Application: which machine runs which operation, maintenance windows, what counts as out of
* sequence, and the load series the strip draws. 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 { civil, formatCivil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { useDropRefusal } from '../kit/use-drop-refusal'
import {
ORDER_TONES,
civilOf,
isOperationEvent,
machineOf,
readOperationFields,
toResources,
type OperationEvent,
} from './adapter'
import { INITIAL_VIEW_START, MACHINES, MAINTENANCE, WEEK, type Operation } from './model'
import { loadSeries, maintenanceAt, slotRefusal, type Refusal, type SequenceIssue } from './rules'
import { ZOOM_IDS, type ForgeText, type ZoomId } from './text'
export interface ForgeSchedulerProps {
readonly events: OperationEvent[]
/** The same operations in model form: the load strip's series is computed from them. */
readonly operations: readonly Operation[]
readonly issues: readonly SequenceIssue[]
/** Operations that sit on a maintenance window. */
readonly conflicts: readonly string[]
readonly text: ForgeText
/** BCP 47 tag for labels, and the scheduler locale id. */
readonly tag: string
readonly schedulerLocale: string
/** Scale on mount. Later changes go through control.zoom so the view animates. */
readonly initialZoom: ZoomId
readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
readonly onSelect: (id: string) => void
readonly onMoved: (id: string) => void
readonly onRefused: (id: string, refusal: Refusal) => void
readonly onZoomLevel: (level: ZoomId) => void
/** The visible range once scrolling settles (civil ISO strings). */
readonly onViewport: (start: string, end: string) => void
}
/** Hour cells for precise moves; one cell per eight-hour shift (00, 08, 16) for the week view. */
const SCALES: Readonly<Record<ZoomId, SuperScheduler.ZoomLevelProperties>> = {
hours: { scale: 'Hour', cellWidth: 48, timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }] },
shifts: {
scale: 'CellDuration',
cellDuration: 480,
cellWidth: 112,
timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
},
}
// Same order as ZOOM_IDS, so onZoom's level index maps back to an id.
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = ZOOM_IDS.map((id) => ({
id,
properties: SCALES[id],
}))
/** Every operation the control holds right now, in the shape the rules read. */
function operationsIn(
control: SuperScheduler.Scheduler,
): Pick<Operation, 'id' | 'machine' | 'start' | 'end'>[] {
return control.events.list.flatMap((event) => {
const machine = event.resource === undefined ? null : machineOf(event.resource)
if (machine === null || !isOperationEvent(event)) return []
return [
{
id: String(event.id),
machine: machine.id,
start: civilOf(event.start),
end: civilOf(event.end),
},
]
})
}
/** Checks one proposed slot of an operation against the plant's rules. */
function refusalFor(
control: SuperScheduler.Scheduler | null,
data: object,
id: string,
resource: SuperScheduler.ResourceId,
start: SuperScheduler.Date,
end: SuperScheduler.Date,
): Refusal | null {
const fields = readOperationFields(data)
const machine = machineOf(resource)
if (control === null || fields === null || machine === null) return null
return slotRefusal(
{ id, kind: fields.kind, start: civil(start.ticks), end: civil(end.ticks) },
machine,
MAINTENANCE,
operationsIn(control),
)
}
export function ForgeScheduler(props: ForgeSchedulerProps) {
const {
events,
operations,
issues,
conflicts,
text,
tag,
schedulerLocale,
initialZoom,
controlRef,
} = props
const { onEventsChange, onSelect, onMoved, onRefused, onZoomLevel, onViewport } = props
const resources = useMemo(() => toResources(text), [text])
const refusal = useDropRefusal(onRefused)
const strip = useRef<HTMLDivElement>(null)
const minimap = useRef<Minimap | null>(null)
// Each order's routing is a chain of finish-to-start links; late ones are drawn in red.
const links = useMemo<SuperScheduler.LinkData[]>(() => {
const late = new Set(issues.map((issue) => issue.operation))
return events.flatMap((event) =>
event.after === undefined
? []
: [
{
id: `${event.after}->${String(event.id)}`,
from: event.after,
to: event.id,
type: 'FinishToStart',
...(late.has(String(event.id)) ? { cssClass: 'ss-link--late' } : {}),
},
],
)
}, [events, issues])
const config = useMemo<SchedulerProps>(() => {
const flagged = new Set([...issues.map((issue) => issue.operation), ...conflicts])
const escape = SuperScheduler.Util.escapeHtml
const messageFor = (reason: Refusal, data: object) => {
const fields = readOperationFields(data)
if (reason === 'machine') return fields === null ? '' : text.rules.wrongMachine[fields.kind]
return text.rules[reason]
}
return {
locale: schedulerLocale,
startDate: WEEK.start,
days: WEEK.days,
zoomLevels: ZOOM_LEVELS,
zoom: initialZoom,
zoomPosition: 'left',
// Three shifts: every hour is production time, so nothing is shaded as non-business.
cellsMarkBusiness: false,
useEventBoxes: 'Never',
height: '100%',
treeEnabled: true,
treePreventParentUsage: true,
rowHeaderWidth: 184,
rowHeaderWidthAutoFit: false,
eventHeight: 28,
rowMarginTop: 5,
rowMarginBottom: 5,
allowEventOverlap: false,
// Run time follows quantity and cycle time: planners move operations, they do not stretch them.
eventResizeHandling: 'Disabled',
durationBarVisible: false,
showToolTip: false,
eventHoverHandling: 'Disabled',
dragCard: { duration: false },
keyboardEnabled: true,
keyboardTarget: 'component',
keyboardMode: 'Full',
controlRef,
onEventsChange,
onBeforeTimeHeaderRender: createHeaderHook(tag),
onBeforeRowHeaderRender: (args) => {
const machine = machineOf(args.row.id)
if (machine === null) {
args.row.cssClass = 'ss-rh--group'
return
}
args.row.html =
`<span class="flex w-full min-w-0 items-center justify-between gap-2">` +
`<span class="truncate font-medium">${escape(args.row.name)}</span>` +
`<span class="shrink-0 text-[11px] text-ink-3">${escape(text.crews[machine.crew])}</span></span>`
},
// Maintenance windows become disabled cells: the library refuses drops on them.
onBeforeCellRender: (args) => {
const machine = machineOf(args.cell.resource)
if (machine === null) return
const cellStart = civil(args.cell.start.ticks)
const window = maintenanceAt(MAINTENANCE, machine.id, cellStart, civil(args.cell.end.ticks))
if (window === null) return
args.cell.properties.disabled = true
args.cell.properties.cssClass = 'ss-cell--maintenance'
if (cellStart <= window.start) {
// The first cell carries the label. The engine clips cells with an inline style, so the
// important utility lets the label run over the rest of the window.
args.cell.properties.cssClass = 'ss-cell--maintenance overflow-visible!'
args.cell.properties.html = `<span class="relative z-[1] block whitespace-nowrap px-1.5 pt-0.5 text-[10.5px] font-semibold text-ink-2">${escape(text.maintenance[window.kind])}</span>`
}
},
onBeforeEventRender: (args) => {
const fields = readOperationFields(args.data)
if (fields === null) return
args.data.html =
`<span class="ss-evt"><span class="ss-evt__title">${escape(fields.order)}</span>` +
`<span class="ss-evt__meta">${escape(text.operations[fields.kind])}</span></span>`
const classes = ['ss-ev', toneClass(ORDER_TONES[fields.order])]
if (flagged.has(String(args.data.id))) classes.push('ss-ev--late')
args.data.cssClass = classes.join(' ')
},
onEventMoving: (args) => {
const id = String(args.e.id())
const reason = refusalFor(
controlRef.current,
args.e.data,
id,
args.resource,
args.start,
args.end,
)
refusal.track(id, reason)
if (reason !== null) {
args.allowed = false
args.message = messageFor(reason, args.e.data)
}
},
// Final check before the commit; keyboard moves (Alt+arrows) arrive here without a drag.
onEventMove: (args) => {
const id = String(args.e.id())
const reason = refusalFor(
args.control,
args.e.data,
id,
args.newResource,
args.newStart,
args.newEnd,
)
if (reason !== null) {
args.preventDefault()
refusal.clear()
onRefused(id, reason)
}
},
onEventMoved: (args) => {
refusal.clear()
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)
},
}
}, [
conflicts,
controlRef,
initialZoom,
issues,
onEventsChange,
onMoved,
onRefused,
onSelect,
onZoomLevel,
refusal,
schedulerLocale,
tag,
text,
])
// Imperative and once: a scroll prop would be re-applied on every update.
useEffect(() => {
controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
}, [controlRef])
// The visible range, published once scrolling (or a minimap drag) settles.
useEffect(() => {
const control = controlRef.current
if (control === null) return
return subscribeScheduler(control, 'viewport', (viewport) =>
onViewport(civil(viewport.start.ticks), civil(viewport.end.ticks)),
)
}, [controlRef, onViewport])
// The load strip reads the latest operations through a ref and redraws when they change.
const latest = useRef(operations)
useEffect(() => {
latest.current = operations
minimap.current?.refresh()
}, [operations])
useEffect(() => {
const control = controlRef.current
const host = strip.current
if (control === null || host === null) return
// The brush reports exact instants (with milliseconds): format their ticks.
const when = (date: SuperScheduler.Date) =>
formatCivil(date.ticks, tag, { weekday: 'short', hour: 'numeric', minute: '2-digit' })
const created = createMinimap(control, host, {
height: 28,
// Absolute scale: a full bar means every available machine is busy.
peak: 'absolute',
max: 1,
series: (range) =>
loadSeries(
latest.current,
MAINTENANCE,
MACHINES,
range.start.ticks,
range.bucketMs,
range.buckets,
),
tone: (value) => (value >= 0.9 ? 'danger' : value >= 0.7 ? 'warn' : 'base'),
// A fixed week of fixtures: no "today" tick, no washed-out past.
marks: { today: false, months: false, past: false },
labels: {
label: text.load.label,
zoom: text.load.zoom,
valueText: (start, end) =>
text.load.range.replace('{start}', when(start)).replace('{end}', when(end)),
},
})
minimap.current = created
return () => {
created.dispose()
minimap.current = null
}
}, [controlRef, 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} links={links} />
</div>
<div className="flex shrink-0 items-center gap-3 border-t border-line bg-paper-2/60 px-3 py-2">
<span className="w-[10.75rem] shrink-0 truncate text-xs font-medium text-ink-2 max-sm:w-auto">
{text.load.title}
</span>
<div ref={strip} className="min-w-0 flex-1" />
</div>
</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.