FabricaciónForge (Negocio ficticio)
Planificación de órdenes de fabricación
El mantenimiento se ha adelantado. Aparta la orden y mantén sus operaciones en secuencia.
- Escala temporal
- Demo con datos sintéticos
- turnos / horas
- Capacidades Pro
- Árbol de recursosDependenciasTiempo bloqueadoMinimapa
Carga SuperScheduler Pro solo cuando lo pides.
La situación
Forge es un taller ficticio de mecanizado a medida: dos fresadoras CNC y un torno, dos células de montaje, una cabina de pintura y un laboratorio de calidad, con cuatro equipos en tres turnos. Cada orden sigue una ruta (fresado, torneado, montaje, pintura, inspección) y ninguna operación puede empezar antes de que termine la anterior.
El mantenimiento no espera al plan. Cuando se adelanta la revisión del cabezal de la Fresadora 2, el fresado previsto en ella tiene que moverse, y lo que le sigue en la ruta puede quedar empezando demasiado pronto. El tablero de planificación debe rechazar movimientos imposibles, mostrar qué operaciones salen de secuencia e indicar dónde la semana ya está llena.
Qué vas a hacerMoverás un fresado fuera de una ventana de mantenimiento, devolverás su torneado a la secuencia y usarás la franja de carga para saltar al siguiente mantenimiento, el jueves.
Dónde termina la librería y empieza tu aplicación
SuperScheduler aporta
- Árbol de líneas y máquinas, arrastre entre máquinas y prevención de solapes por máquina.
- Celdas deshabilitadas que rechazan soltar sobre el mantenimiento, con vetos y mensajes por fotograma.
- Vínculos fin-inicio entre operaciones, con estilo propio en cada vínculo.
- Escalas de horas y turnos con zoom animado, movimiento por teclado y anuncios.
- El minimapa de super-scheduler/minimap y el estado del viewport de super-scheduler/hooks.
El código de este ejemplo decide
- Qué tipo de máquina hace cada operación, y el calendario de mantenimiento.
- La ruta de cada orden y la comprobación que marca una operación que empieza demasiado pronto.
- La serie de carga: tiempo de máquina ocupado sobre tiempo disponible, descontando el mantenimiento.
- Duraciones fijas (sin redimensionar), el panel de detalle, la misión y todos los textos.
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: escalas de horas y turnos, el mantenimiento como celdas deshabilitadas, los vínculos de secuencia, los rechazos y la franja de carga con su serie calculada.
/**
* 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>
)
}Comparte este escenario
El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.