Saltar al contenido
SuperScheduler

Servicio técnicoFieldwork (Negocio ficticio)

Despacho de servicio técnico

Entra un trabajo urgente en la cola. Encuentra la cuadrilla que puede atenderlo a tiempo.

Escala temporal
Demo con datos sintéticos
horas / días
Capacidades Pro
Arrastrar desde fueraFiltrosMultiselecciónPanel de detalle
Fieldwork
NorteCuadrilla N1ClimaCuadrilla N2Elec.CentroCuadrilla C1Clima · Elec.Cuadrilla C2Font.SurCuadrilla S1Font. · ClimaCuadrilla S2Elec.J-5101 MantenimientoJ-5103 InstalaciónJ-5114 MantenimientoJ-5102 ReparaciónJ-5104 MantenimientoJ-5105 InspecciónJ-5106 InstalaciónJ-5115 ReparaciónJ-5108 ReparaciónJ-5109 InstalaciónJ-5117 InstalaciónJ-5112 MantenimientoJ-5113 ReparaciónJ-5116 InstalaciónJ-5110 ReparaciónJ-5111 Inspecciónlunes, 26 de octubremartes, 27 de octubremiércoles, 28 de octubre08:0009:0010:0011:0012:0013:0014:0015:0016:0017:0008:0009:0010:0011:0012:0013:0014:0015:0016:0017:0008:0009:00

Carga SuperScheduler Pro solo cuando lo pides.

La situación

Fieldwork es una empresa ficticia de servicio técnico con seis cuadrillas en tres regiones. Cada cuadrilla tiene sus oficios (climatización, electricidad, fontanería), trabaja de 08:00 a 18:00 y solo cubre su región. Los trabajos llegan con una estimación, una zona y una hora límite, y algunos son urgentes.

La mañana de quien despacha es una cola y un tablero. Soltar un trabajo en la cuadrilla equivocada cuesta una visita; pasarse de la hora límite incumple un acuerdo de servicio. El tablero tiene que decir, mientras se arrastra el trabajo, si una cuadrilla puede atenderlo y por qué no, y ofrecer la misma comprobación sin ratón. No planifica rutas: el desplazamiento es cosa de la cuadrilla.

Qué vas a hacerReducirás el tablero a un oficio, colocarás un trabajo urgente en la única cuadrilla que llega a tiempo y devolverás a la cola los trabajos de una cuadrilla parada en un solo paso.

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

SuperScheduler aporta

  • Elementos de la página arrastrados al tablero con SuperScheduler.Scheduler.makeDraggable, que llegan por onEventMoving y onEventMoved (args.external).
  • Vetos por fotograma con mensaje en la tarjeta de arrastre, celdas deshabilitadas para las ausencias y prevención de solapes por cuadrilla.
  • Filtrado de filas con rows.filter y onRowFilter, manteniendo las regiones como encabezados.
  • Selección múltiple de eventos (Ctrl/⌘+clic) leída desde multiselect, más movimiento y foco por teclado.
  • Escalas de día y semana con las noches ocultas, y eventos controlados con onEventsChange.

El código de este ejemplo decide

  • Qué cuadrilla puede hacer cada trabajo: oficio, región, horario, ausencias y hora límite.
  • La propia cola: todo trabajo que no está en el tablero, así que devolver trabajos es una sola actualización de estado.
  • El panel Asignar: el primer hueco libre de cada cuadrilla según las reglas, sin rutas ni tiempos de desplazamiento.
  • El panel de detalle, la misión y todos los textos de esta página.

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, el filtro de filas, la selección múltiple, las ausencias como celdas deshabilitadas y los callbacks que comprueban cada hueco, también al soltar desde la cola.

scheduler.tsxtsx
/**
 * Fieldwork dispatch board: crews grouped by region over one working week. Jobs arrive from the
 * queue below the board (an external drag, see ./tray) or move between crews on the board.
 *
 * Library: tree rows, row filtering, external drops, drag between rows with per-frame vetoes,
 * event multi-selection, disabled cells, day and week scales, keyboard moves.
 * Application: which crew can take which job (skill, region, working hours, availability, due
 * time). Those rules live in ./rules; nothing here plans routes or travel.
 */
import { useEffect, useMemo, type RefObject } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import { civil } from '../../scheduler/civil'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import { useDropRefusal } from '../kit/use-drop-refusal'
import {
  SKILL_TONES,
  crewOf,
  readJobFields,
  toBooking,
  toResources,
  type JobEvent,
} from './adapter'
import { BLOCKS, SKILLS, WEEK, WORKING_HOURS, type Skill } from './model'
import { slotRefusal, type Booking, type Refusal } from './rules'
import { ZOOM_IDS, type FieldworkText, type SkillFilter, type ZoomId } from './text'

export interface FieldworkSchedulerProps {
  readonly events: JobEvent[]
  readonly text: FieldworkText
  /** 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
  /** Crews shown: every crew, or those with one skill. */
  readonly filter: SkillFilter
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  /** The selected jobs, after every click, Ctrl/⌘+click or keyboard selection. */
  readonly onSelection: (ids: readonly string[]) => void
  /** `external`: the job came from the queue. */
  readonly onMoved: (id: string, external: boolean) => void
  readonly onRefused: (id: string, refusal: Refusal) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** A working day in hours, or the week in two-hour cells. */
const SCALES: Readonly<Record<ZoomId, SuperScheduler.ZoomLevelProperties>> = {
  day: { scale: 'Hour', cellWidth: 60, timeHeaders: [{ groupBy: 'Day' }, { groupBy: 'Hour' }] },
  week: {
    scale: 'CellDuration',
    cellDuration: 120,
    cellWidth: 48,
    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],
}))

function isSkill(value: unknown): value is Skill {
  return typeof value === 'string' && (SKILLS as readonly string[]).includes(value)
}

/** Every job the control holds right now, as bookings the rules read. */
function bookingsIn(control: SuperScheduler.Scheduler): Booking[] {
  return control.events.list.flatMap((event) => toBooking(event) ?? [])
}

/** Checks one proposed slot of a job (on the board or dragged from the queue) for a crew. */
function refusalFor(
  control: SuperScheduler.Scheduler | null,
  data: object,
  id: string,
  resource: SuperScheduler.ResourceId,
  start: SuperScheduler.Date,
  end: SuperScheduler.Date,
): Refusal | null {
  const fields = readJobFields(data)
  const crew = crewOf(resource)
  if (control === null || fields === null || crew === null) return null
  return slotRefusal(
    { id, ...fields },
    crew,
    civil(start.ticks),
    civil(end.ticks),
    WORKING_HOURS,
    BLOCKS,
    bookingsIn(control),
  )
}

export function FieldworkScheduler(props: FieldworkSchedulerProps) {
  const { events, text, tag, schedulerLocale, initialZoom, filter, controlRef } = props
  const { onEventsChange, onSelection, onMoved, onRefused, onZoomLevel } = props

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

  const config = useMemo<SchedulerProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const selectionOf = (control: SuperScheduler.Scheduler) =>
      control.multiselect.events().map((event) => String(event.id()))

    return {
      locale: schedulerLocale,
      startDate: WEEK.start,
      days: WEEK.days,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Crews are on the road 08:00-18:00: evenings and nights are not drawn.
      businessBeginsHour: WORKING_HOURS.begin,
      businessEndsHour: WORKING_HOURS.end,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderWidth: 176,
      rowHeaderWidthAutoFit: false,
      eventHeight: 28,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      allowEventOverlap: false,
      // Time on site is the job's estimate: dispatchers move jobs, they do not stretch them.
      eventResizeHandling: 'Disabled',
      // A click selects a job; Ctrl/⌘+click adds more for a bulk action.
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      dragCard: { duration: false },
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: createHeaderHook(tag),

      onBeforeRowHeaderRender: (args) => {
        const crew = crewOf(args.row.id)
        if (crew === null) {
          args.row.cssClass = 'ss-rh--group'
          return
        }
        const skills = crew.skills.map((skill) => text.skillsShort[skill]).join(' · ')
        args.row.html =
          `<span class="flex w-full min-w-0 items-center justify-between gap-2">` +
          `<span class="shrink-0 font-medium">${escape(args.row.name)}</span>` +
          `<span class="truncate text-[11px] text-ink-3">${escape(skills)}</span></span>`
      },

      // The skill filter hides crews without that skill; region rows stay as headings.
      onRowFilter: (args) => {
        const crew = crewOf(args.row.id)
        if (crew !== null && isSkill(args.filterParam))
          args.visible = crew.skills.includes(args.filterParam)
      },

      // Training and vehicle service: disabled cells refuse drops for that crew.
      onBeforeCellRender: (args) => {
        const crew = crewOf(args.cell.resource)
        if (crew === null) return
        const start = civil(args.cell.start.ticks)
        const block = BLOCKS.find(
          (candidate) =>
            candidate.crew === crew.id &&
            candidate.start < civil(args.cell.end.ticks) &&
            start < candidate.end,
        )
        if (block === undefined) return
        args.cell.properties.disabled = true
        if (start <= block.start) {
          // The engine clips cells with an inline style; the important utility lets the label of
          // the block's first cell run over the rest of the block.
          args.cell.properties.cssClass = '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-3">${escape(text.blocks[block.kind])}</span>`
        }
      },

      onBeforeEventRender: (args) => {
        const fields = readJobFields(args.data)
        if (fields === null) return
        // The job code always shows; the job type gives way when the bar is short.
        const urgent = fields.priority === 'urgent' ? `<span class="ss-evt__badge">!</span>` : ''
        args.data.html =
          `<span class="flex min-w-0 items-baseline gap-1.5 whitespace-nowrap">${urgent}` +
          `<span class="shrink-0">${escape(String(args.data.id))}</span>` +
          `<span class="truncate font-medium opacity-75">${escape(text.types[fields.type])}</span></span>`
        const classes = ['ss-ev', toneClass(SKILL_TONES[fields.skill])]
        // Selected jobs are outlined here: without a `theme` the control adds no class of its own
        // to selected events. The selection redraw re-runs this hook for the jobs that changed.
        const self = args.control.events.find(args.data.id)
        if (self !== null && args.control.multiselect.isSelected(self))
          classes.push('z-[2] outline-2 outline-offset-1 outline-ink')
        args.data.cssClass = classes.join(' ')
      },

      // Runs for moves on the board and for jobs dragged in from the queue (args.external).
      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 = text.rules[reason]
        }
      },

      // 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()), args.external)
      },
      onEventSelected: () => {
        const control = controlRef.current
        if (control !== null) onSelection(selectionOf(control))
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    controlRef,
    initialZoom,
    onEventsChange,
    onMoved,
    onRefused,
    onSelection,
    onZoomLevel,
    refusal,
    schedulerLocale,
    tag,
    text,
  ])

  // Row filtering is imperative: the control re-runs onRowFilter for every row.
  useEffect(() => {
    controlRef.current?.rows.filter(filter === 'all' ? null : filter)
  }, [controlRef, filter])

  return <SuperSchedulerComponent {...config} resources={resources} events={events} />
}
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 aulas de formaciónLa matrícula superó el aula. Selecciona ambas sesiones, mira qué está libre para las dos, muévelas juntas y conserva la vista.Leer el caso de negocioServicio técnico