Saltar al contenido
SuperScheduler

LogísticaHarborworks (Negocio ficticio)

Planificación de atraques

Un buque llega doce horas tarde. Mueve su ventana de atraque y lleva con ella el remolcador y las grúas.

Escala temporal
Demo con datos sintéticos
horas / días
Capacidades Pro
Eventos de varios díasÁrbol de recursosDependenciasZoom continuoMinimapa
Harborworks
Servicios náuticosRemolcador Petrel70 tRemolcador Alca60 tMuelleMuelle 1340 m · 16 mMuelle 2300 m · 14 mMuelle 3220 m · 12 mGrúasSTS 1Muelles 1–2STS 2Muelles 1–2STS 3Muelles 1–2Grúa móvil 4Muelle 3Sella TraderFjord LanternMarisolSella TraderMarisolMantenimiento de grúaCape SolanoOrinoco StarCorvinaCape SolanoOrinoco StarAtlas MeridianMarisolKestrel BayAtlas MeridianKestrel BayAtlas MeridianKestrel BayAtlas MeridianMantenimiento de grúaFjord LanternOrinoco Starmiércoles, 21 de octubrejueves, 22 de octubreviernes, 23 de octubresábado, 24 de octubredomingo, 25 de octubre00:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:0020:0000:0004:0008:0012:0016:00

Carga SuperScheduler Pro solo cuando lo pides.

La situación

Harborworks es una terminal ficticia con tres muelles, tres grúas pórtico, una grúa móvil y dos remolcadores. El planificador de atraques trabaja las 24 horas: cada escala necesita un muelle con eslora y calado suficientes, un remolcador que acerque el buque y grúas que alcancen ese muelle.

Los retrasos son lo normal. Cuando un buque comunica una nueva hora de llegada, su ventana se mueve y todo lo que depende de ella también: el remolque, las manos de estiba y a veces el buque siguiente. Un tablero que solo muestra barras esconde esa cascada; este dibuja los vínculos, marca lo que ya no encaja y mantiene a la vista la ocupación del muelle.

Qué vas a hacerMoverás un buque retrasado a su nueva hora de atraque, realinearás su remolcador y sus grúas con un solo comando y resolverás la grúa que coincide con un mantenimiento.

Dónde termina la librería y empieza tu aplicación

SuperScheduler aporta

  • Árbol de recursos agrupado con remolcadores, muelles y grúas, y una cabecera de fila propia para cada equipo.
  • Barras largas a la hora exacta (useEventBoxes: 'Never') que se arrastran por celdas completas con eventMoveByCell.
  • Vínculos inicio-inicio y fin-inicio dibujados a partir de tus datos, con una clase CSS por vínculo.
  • Niveles de zoom de horas a días, con gestos y un cambio animado.
  • Minimapa con un selector de vista que desplaza y hace zoom, además de prevención de solapes, historial y movimientos con teclado.

El código de este ejemplo decide

  • Qué buque cabe en qué muelle (eslora y calado) y qué grúa alcanza qué muelle: los rechazos en onEventMoving.
  • Qué es un conflicto: una ventana antes de su ETB, una grúa fuera de su ventana, un remolque que no termina al atracar.
  • El comando de realinear: una función pura que mueve las operaciones vinculadas y registra un único paso de deshacer.
  • La serie de ocupación del muelle que alimenta el minimapa y el resumen del día con más carga.

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: niveles de zoom, barras a la hora exacta, vínculos construidos desde los datos y los callbacks donde las reglas de la terminal rechazan un movimiento.

scheduler.tsxtsx
/**
 * Harborworks berth plan: tugs, berths and cranes over two weeks of vessel calls, round the clock.
 *
 * Library: grouped resource tree, long events at exact times, drag/resize, links, zoom levels,
 * keyboard and undo history. Application: which ship fits which berth, which crane reaches which
 * berth, and what counts as a conflict. Those rules live in ./rules and run in the callbacks below.
 */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { format, plural } from '../../i18n/format'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
  BOARD,
  assetMeta,
  callById,
  isAssetId,
  readOperationFields,
  toResources,
  toneOf,
  type OperationEvent,
} from './adapter'
import { INITIAL_VIEW_START, PLAN, type Operation, type VesselCall } from './model'
import { berthById, moveRefusal, type Issue, type MoveRefusal } from './rules'
import type { HarborText, ZoomId } from './text'

export interface HarborSchedulerProps {
  readonly events: OperationEvent[]
  /** The same events as business objects, for rules that look at other operations. */
  readonly operations: readonly Operation[]
  readonly calls: readonly VesselCall[]
  readonly issues: readonly Issue[]
  readonly text: HarborText
  /** 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 showLinks: boolean
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string, edge: 'start' | 'end') => void
  readonly onRefused: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** Four-hour watches by default; hours to plan a berthing, days to see the fortnight. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'hours',
    properties: {
      scale: 'Hour',
      cellWidth: 40,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }],
    },
  },
  {
    id: 'watches',
    properties: {
      scale: 'CellDuration',
      cellDuration: 240,
      cellWidth: BOARD.cellWidth,
      timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Cell' }],
    },
  },
  {
    id: 'days',
    properties: {
      scale: 'Day',
      cellWidth: 150,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['hours', 'watches', 'days']

/** A refusal message with the numbers of the ship and the berth it was dropped on. */
function refusalMessage(
  reason: MoveRefusal,
  call: VesselCall | null,
  target: SuperScheduler.ResourceId,
  text: HarborText,
  tag: string,
): string {
  const berth = berthById(String(target))
  const number = (value: number) =>
    new Intl.NumberFormat(tag, { maximumFractionDigits: 1 }).format(value)
  return format(text.rules[reason], {
    length: number(call?.length ?? 0),
    draft: number(call?.draft ?? 0),
    berthLength: number(berth?.length ?? 0),
    berthDraft: number(berth?.draft ?? 0),
  })
}

export function HarborScheduler(props: HarborSchedulerProps) {
  const {
    events,
    operations,
    calls,
    issues,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    showLinks,
    history,
    controlRef,
  } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])

  // Header labels depend on the level's scale. A zoom animation lands on the new scale after the
  // headers of its last frames were drawn, so each settled level gets a fresh hook: a new hook
  // makes the control label its headers again.
  const [level, setLevel] = useState<ZoomId>(initialZoom)
  const timeHeaders = useMemo(() => ({ level, hook: createHeaderHook(tag) }), [level, tag]).hook

  // A drop refused in onEventMoving never reaches onEventMove: announce the refusal of the last
  // drag frame when the pointer is released (a frame that is allowed again clears it).
  const refusal = useRef<string | null>(null)
  const refuseOnRelease = useCallback(
    (message: string | null) => {
      if (message !== null && refusal.current === null) {
        window.addEventListener(
          'pointerup',
          () => {
            setTimeout(() => {
              if (refusal.current !== null) onRefused(refusal.current)
              refusal.current = null
            }, 0)
          },
          { once: true },
        )
      }
      refusal.current = message
    },
    [onRefused],
  )

  // Links come from the business model: tugs finish before berthing, cranes start after it.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    if (!showLinks) return []
    const flagged = new Set(issues.map((issue) => issue.op))
    const windows = new Map(
      events.flatMap((event) =>
        event.kind === 'berth' && event.call !== undefined ? [[event.call, event.id]] : [],
      ),
    )
    return events.flatMap((event): SuperScheduler.LinkData[] => {
      const window = event.call === undefined ? undefined : windows.get(event.call)
      if (window === undefined || (event.kind !== 'tug' && event.kind !== 'crane')) return []
      const late = flagged.has(String(event.id)) ? { cssClass: 'ss-link--late' } : {}
      return event.kind === 'tug'
        ? [
            {
              id: `${String(event.id)}->${String(window)}`,
              from: event.id,
              to: window,
              type: 'FinishToStart',
              ...late,
            },
          ]
        : [
            {
              id: `${String(window)}->${String(event.id)}`,
              from: window,
              to: event.id,
              type: 'StartToStart',
              ...late,
            },
          ]
    })
  }, [events, issues, showLinks])

  const config = useMemo<SchedulerProps>(() => {
    const flagged = new Set(issues.map((issue) => issue.op))

    return {
      locale: schedulerLocale,
      startDate: PLAN.start,
      days: PLAN.days,
      // ISO weeks in every locale, as the static preview draws them.
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Exact times (a window from 06:30 stays at 06:30); drags move by whole cells.
      useEventBoxes: 'Never',
      eventMoveByCell: true,
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: BOARD.rowHeaderWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: BOARD.eventHeight,
      rowMarginTop: BOARD.rowMarginTop,
      rowMarginBottom: BOARD.rowMarginBottom,
      allowEventOverlap: false,
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeRowHeaderRender: (args) => {
        const id = args.row.id
        if (!isAssetId(id)) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        const name = SuperScheduler.Util.escapeHtml(args.row.name)
        const meta = SuperScheduler.Util.escapeHtml(assetMeta(id, text))
        args.row.html =
          `<span class="flex min-w-0 items-baseline justify-between gap-2"><span class="truncate">${name}</span>` +
          `<span class="shrink-0 text-[11px] text-ink-3 tabular-nums">${meta}</span></span>`
      },

      onBeforeEventRender: (args) => {
        const fields = readOperationFields(args.data)
        if (fields === null) return
        const call = callById(fields.call)
        const title = SuperScheduler.Util.escapeHtml(call?.vessel ?? text.maintenance)
        const meta =
          fields.kind === 'crane' && fields.moves !== undefined
            ? plural(text.moves, fields.moves, tag)
            : fields.kind === 'berth' && call !== null
              ? call.id
              : ''
        const details =
          meta === ''
            ? ''
            : `<span class="ss-evt__meta">${SuperScheduler.Util.escapeHtml(meta)}</span>`
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span>${details}</span>`
        const classes = ['ss-ev', toneClass(toneOf(fields))]
        if (fields.kind === 'maintenance') classes.push('ss-ev--hatched')
        if (call?.planned === true) classes.push('ss-ev--ghost')
        if (flagged.has(String(args.data.id))) classes.push('hw-conflict')
        args.data.cssClass = classes.join(' ')
      },

      // Berth size, crane reach and asset kinds are terminal rules; overlap is enforced by the library.
      onEventMoving: (args) => {
        const fields = readOperationFields(args.e.data)
        if (fields === null) return
        const reason = moveRefusal(fields, String(args.resource), calls, operations)
        const message =
          reason === null
            ? null
            : refusalMessage(reason, callById(fields.call), args.resource, text, tag)
        if (message !== null) {
          args.allowed = false
          args.message = message
        }
        refuseOnRelease(message)
      },

      // Final check before the commit (also covers keyboard moves).
      onEventMove: (args) => {
        const fields = readOperationFields(args.e.data)
        if (fields === null) return
        const reason = moveRefusal(fields, String(args.newResource), calls, operations)
        if (reason !== null) {
          args.preventDefault()
          onRefused(refusalMessage(reason, callById(fields.call), args.newResource, text, tag))
        }
      },

      onEventMoved: (args) => onMoved(String(args.e.id())),
      onEventResized: (args) => onResized(String(args.e.id()), args.what),
      onEventClick: (args) => onSelect(String(args.e.id())),

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = ZOOM_IDS[args.level]
        if (next === undefined) return
        setLevel(next)
        onZoomLevel(next)
      },
    }
  }, [
    history,
    controlRef,
    issues,
    calls,
    operations,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onZoomLevel,
    refuseOnRelease,
    schedulerLocale,
    tag,
    text,
    initialZoom,
  ])

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

  return (
    <SuperSchedulerComponent
      {...config}
      onBeforeTimeHeaderRender={timeHeaders}
      resources={resources}
      events={events}
      links={links}
    />
  )
}
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 relacionadoPlanificación de órdenes de fabricaciónEl mantenimiento se ha adelantado. Aparta la orden y mantén sus operaciones en secuencia.Leer el caso de negocioPuertos y logística