---
title: "React Timeline Truck Dispatch Planning Example | Mobiscroll"
component: "Timeline"
framework: "React"
library_version: "6.2.0"
url: "https://demo.mobiscroll.com/react/timeline/truck-transport-planning-and-dispatch"
---

# Truck Dispatch Planning - React Timeline demo

Assign transports on the React timeline — hide trucks with capacity or time-window conflicts during drag and snap failed drops to the nearest valid slot. For React and Next.JS.

- [Live demo](https://demo.mobiscroll.com/react/timeline/truck-transport-planning-and-dispatch)
- [View React JS code on GitHub](https://github.com/acidb/mobiscroll-demos-react/tree/main/src/demos/eventcalendar/timeline/truck-transport-planning-and-dispatch)
- [View React TS code on GitHub](https://github.com/acidb/mobiscroll-demos-react-ts/tree/main/src/demos/eventcalendar/timeline/truck-transport-planning-and-dispatch)
- This demo in other frameworks: [JavaScript](https://demo.mobiscroll.com/javascript/timeline/truck-transport-planning-and-dispatch.md), [jQuery](https://demo.mobiscroll.com/jquery/timeline/truck-transport-planning-and-dispatch.md), [Angular](https://demo.mobiscroll.com/angular/timeline/truck-transport-planning-and-dispatch.md), [Vue](https://demo.mobiscroll.com/vue/timeline/truck-transport-planning-and-dispatch.md)

## Description

Managing a fleet of delivery trucks means constantly matching incoming jobs against available vehicles - checking cargo capacity, time windows, and maintenance schedules, all at once. This demo brings that workflow onto an interactive timeline where dispatchers can drag unscheduled jobs from a sidebar directly onto a truck row without leaving the screen.

To enable external drops from the sidebar, set [`externalDrop`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-externalDrop) to `true` on the timeline, and initialize each job card in the sidebar using the `draggable` component with `dragData` pointing to the full job object.

When the dispatcher starts dragging a job, the [`onEventDragStart`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventDragStart) event fires and drives a real-time resource validation pass: trucks with insufficient cargo capacity or a conflicting scheduled trip are filtered out of the resource list, and the grid is locked outside the job's pickup-to-drop-off window using the [`invalid`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-invalid) option. This gives the dispatcher an immediate visual map of valid drop targets.

A successful drop is confirmed via [`onEventCreated`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventCreated) , which removes the job from the sidebar and marks it as scheduled. If the drop lands in an invalid position, [`onEventCreateFailed`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventCreateFailed) snaps the job to the first free slot within the time window instead of rejecting the drop outright. The same snap logic runs for existing event moves via [`onEventUpdateFailed`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventUpdateFailed). The trucks are grouped into capacity-tier categories and rendered using [`renderResource`](https://mobiscroll.com/docs/react/eventcalendar/api#renderer-renderResource), which shows each truck's name, license plate, and a colour-coded maintenance-status dot. Each truck has a parallel "actual" sub-row that tracks live trip progress and is refreshed automatically every 60 seconds.

**Looking for a simpler external drag & drop example?** [Check out the external drag & drop example →](https://demo.mobiscroll.com/react/timeline/external-drag-drop-sortable-dragula)

## What this demo shows

- The demo shows a full-page truck dispatch and planning console that combines an unscheduled job queue with a resource timeline.
- **Job queue** The left sidebar lists unscheduled transport jobs in a scrollable queue, grouped by date under sticky day headers such as "Thu, 22 May".
- **Job cards** Each job card shows the pickup and delivery addresses, the pickup-to-drop time window, and the cargo weight in tonnes.
- **Scheduling flow** Jobs disappear from the sidebar after they are successfully assigned to a truck on the timeline.
- **Timeline layout** The main panel uses a timeline Eventcalendar view with trucks shown as resources.
- **Resource details** Each truck row displays the truck name, license plate, and a colour-coded status dot for operational, maintenance planned, or in maintenance states.
- **Resource grouping** Trucks are grouped under collapsible capacity tiers such as 3.5 t, 7 t, 12 t, 20 t, and 24 t.
- **Actual rows** Each truck includes a second, indented actual sub-row rendered in a lighter style for live trip progress.
- **Toolbar** A custom toolbar above the timeline includes a date-range label button, a segmented status filter, zoom controls with minus and plus buttons, and a Now button.
- **Date range selection** The date-range button opens a range picker popup for changing the visible planning window.
- **Status filtering** The segmented control filters the visible events by Scheduled, In Progress, and Completed states.
- **Zooming** The zoom slider changes the timeline across multiple detail levels, from broader 4-hour cells down to finer 15-minute cells.
- **Event styling** Timeline events are colour-coded by status, such as blue for scheduled, orange for in progress, and grey for completed.
- **Event content** Planned events show the route between the pickup and delivery locations together with a Planned badge.
- **Live progress display** In-progress trips on the actual sub-row show a pulsing In progress label.
- **Drag validation** When a job is dragged from the sidebar, trucks that cannot take the job because of capacity limits or time conflicts are hidden from the resource list.
- **Invalid ranges** During drag, invalid time ranges are greyed out so only valid drop targets remain visible on the timeline.
- **Successful drop** Dropping a job onto a valid slot adds it to the timeline and confirms the action with a toast message.
- **Auto-snap behavior** Dropping onto an invalid slot snaps the job to the nearest available valid slot within the allowed window when possible.
- **Failure handling** If no valid slot can be found, the demo shows an error toast instead of placing the job.
- **Actual event details** Clicking an actual-progress event opens a toast with delay and elapsed or arrival information for the transport.

## Best for

- **Truck dispatching** Planning and assigning incoming transport jobs across a fleet of trucks from a single scheduling screen.
- **Logistics operations** Managing deliveries where dispatchers need to balance cargo capacity, time windows, and vehicle availability at the same time.
- **Constraint-based scheduling** Evaluating valid assignments quickly when jobs can only be placed on resources that meet size and timing requirements.
- **Live operations visibility** Showing both the scheduled plan and the actual trip progress in the same timeline view.
- **Large resource sets** Working with bigger fleets where drag-time filtering helps narrow the visible list down to valid trucks only.

## API used in this demo

- [`theme`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-theme) - option
- [`themeVariant`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-themeVariant) - option
- [`cssClass`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-cssClass) - option
- [`getEvents`](https://mobiscroll.com/docs/react/eventcalendar/api#method-getEvents) - method
- [`navigateToEvent`](https://mobiscroll.com/docs/react/eventcalendar/api#method-navigateToEvent) - method
- [`width`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-width) - option
- [`display`](https://mobiscroll.com/docs/react/popup/api#opt-display) - option
- [`touchUi`](https://mobiscroll.com/docs/react/popup/api#opt-touchUi) - option
- [`buttons`](https://mobiscroll.com/docs/react/popup/api#opt-buttons) - option
- [`cssClass`](https://mobiscroll.com/docs/react/popup/api#opt-cssClass) - option
- [`scrollLock`](https://mobiscroll.com/docs/react/popup/api#opt-scrollLock) - option
- [`maxWidth`](https://mobiscroll.com/docs/react/popup/api#opt-maxWidth) - option
- [`clickToCreate`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-clickToCreate) - option
- [`dragToCreate`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-dragToCreate) - option
- [`dragToResize`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-dragToResize) - option
- [`dragToMove`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-dragToMove) - option
- [`externalDrop`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-externalDrop) - option
- [`eventOverlap`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-eventOverlap) - option
- [`refDate`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-refDate) - option
- [`zoomLevel`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-zoomLevel) - option
- [`view`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-view) - option
- [`data`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-data) - option
- [`resources`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-resources) - option
- [`invalid`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-invalid) - option
- [`renderResource`](https://mobiscroll.com/docs/react/eventcalendar/api#renderer-renderResource) - renderer
- [`renderResourceEmpty`](https://mobiscroll.com/docs/react/eventcalendar/api#renderer-renderResourceEmpty) - renderer
- [`renderResourceHeader`](https://mobiscroll.com/docs/react/eventcalendar/api#renderer-renderResourceHeader) - renderer
- [`renderTimelineEventContent`](https://mobiscroll.com/docs/react/eventcalendar/api#renderer-renderTimelineEventContent) - renderer
- [`onEventCreated`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventCreated) - event
- [`onEventCreateFailed`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventCreateFailed) - event
- [`onEventUpdateFailed`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventUpdateFailed) - event
- [`onEventDragStart`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventDragStart) - event
- [`onEventDragEnd`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventDragEnd) - event
- [`onEventClick`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventClick) - event
- [`onPageLoaded`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onPageLoaded) - event
- [`focusOnClose`](https://mobiscroll.com/docs/react/popup/api#opt-focusOnClose) - option
- [`showOverlay`](https://mobiscroll.com/docs/react/popup/api#opt-showOverlay) - option
- [`width`](https://mobiscroll.com/docs/react/popup/api#opt-width) - option
- [`anchor`](https://mobiscroll.com/docs/react/popup/api#opt-anchor) - option
- [`responsive`](https://mobiscroll.com/docs/react/popup/api#opt-responsive) - option
- [`label`](https://mobiscroll.com/docs/react/select/api#opt-label) - option
- [`labelStyle`](https://mobiscroll.com/docs/react/select/api#opt-labelStyle) - option
- [`inputStyle`](https://mobiscroll.com/docs/react/select/api#opt-inputStyle) - option
- [`data`](https://mobiscroll.com/docs/react/select/api#opt-data) - option
- [`onChange`](https://mobiscroll.com/docs/react/select/api#event-onChange) - event
- [`display`](https://mobiscroll.com/docs/react/datepicker/api#opt-display) - option
- [`controls`](https://mobiscroll.com/docs/react/datepicker/api#opt-controls) - option
- [`select`](https://mobiscroll.com/docs/react/datepicker/api#opt-select) - option
- [`showRangeLabels`](https://mobiscroll.com/docs/react/datepicker/api#opt-showRangeLabels) - option
- [`pages`](https://mobiscroll.com/docs/react/datepicker/api#opt-pages) - option
- [`showOnClick`](https://mobiscroll.com/docs/react/datepicker/api#opt-showOnClick) - option
- [`showOnFocus`](https://mobiscroll.com/docs/react/datepicker/api#opt-showOnFocus) - option
- [`startInput`](https://mobiscroll.com/docs/react/datepicker/api#opt-startInput) - option
- [`endInput`](https://mobiscroll.com/docs/react/datepicker/api#opt-endInput) - option
- [`onChange`](https://mobiscroll.com/docs/react/datepicker/api#event-onChange) - event
- [`onClose`](https://mobiscroll.com/docs/react/notifications/toast#event-onClose) - event

## Code

### JSX

```jsx
import {
  Button,
  Checkbox,
  Datepicker,
  Draggable,
  Eventcalendar,
  formatDate,
  Input,
  Page,
  Popup,
  Segmented,
  SegmentedGroup,
  Select,
  setOptions,
  Toast,
} from '@mobiscroll/react';
import PropTypes from 'prop-types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

setOptions({
  theme: 'ios',
  themeVariant: 'light'
});

const statusColors = {
  scheduled: '#2196f3',
  'in progress': '#f97316',
  completed: '#78909c',
};

const maintenanceColors = {
  'in-maintenance': '#f44336',
  'maintenance-planned': '#ff9800',
  operational: '#4caf50',
};

const maintenanceLabels = {
  'in-maintenance': 'In Maintenance',
  'maintenance-planned': 'Maintenance Planned',
  operational: 'Operational',
};

const allResources = [
  {
    id: 'cap1',
    name: '3.5 tons capacity',
    eventCreation: false,
    children: [
      { id: 1, name: 'Isuzu N-Series N35', capacity: 3.5, status: 'operational', plate: 'AB14 KTP' },
      {
        id: '1-actual',
        name: '',
        capacity: 3.5,
        status: 'operational',
        plate: 'AB14 KTP',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 2, name: 'Mercedes-Benz Sprinter 3500', capacity: 3.5, status: 'operational', plate: 'FR19 XDL' },
      {
        id: '2-actual',
        name: '',
        capacity: 3.5,
        status: 'operational',
        plate: 'FR19 XDL',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 3,
        name: 'Ford Transit 350',
        capacity: 3.5,
        status: 'operational',
        plate: 'QN62 RPV',
        maintenanceFrom: '2026-11-01T00:00',
        maintenanceTo: '2026-11-05T00:00',
      },
      {
        id: '3-actual',
        name: '',
        capacity: 3.5,
        status: 'operational',
        plate: 'QN62 RPV',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-01T00:00',
        maintenanceTo: '2026-11-05T00:00',
      },
    ],
  },
  {
    id: 'cap2',
    name: '7 tons capacity',
    eventCreation: false,
    children: [
      { id: 4, name: 'Ford F-650 Super Duty', capacity: 7, status: 'operational', plate: 'GR12 PEV' },
      {
        id: '4-actual',
        name: '',
        capacity: 7,
        status: 'operational',
        plate: 'GR12 PEV',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 5, name: 'Isuzu F-Series FTR', capacity: 7, status: 'operational', plate: 'MB27 FTK' },
      {
        id: '5-actual',
        name: '',
        capacity: 7,
        status: 'operational',
        plate: 'MB27 FTK',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 6,
        name: 'Hino 300 Series 716',
        capacity: 7,
        status: 'operational',
        plate: 'KT73 ZLD',
        maintenanceFrom: '2026-11-05T00:00',
        maintenanceTo: '2026-11-08T00:00',
      },
      {
        id: '6-actual',
        name: '',
        capacity: 7,
        status: 'operational',
        plate: 'KT73 ZLD',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-05T00:00',
        maintenanceTo: '2026-11-08T00:00',
      },
    ],
  },
  {
    id: 'cap3',
    name: '12 tons capacity',
    eventCreation: false,
    children: [
      { id: 7, name: 'Hino 500 Series FD', capacity: 12, status: 'operational', plate: 'EP17 GMF' },
      {
        id: '7-actual',
        name: '',
        capacity: 12,
        status: 'operational',
        plate: 'EP17 GMF',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 8, name: 'Isuzu F-Series FVR', capacity: 12, status: 'operational', plate: 'DS41 CXP' },
      {
        id: '8-actual',
        name: '',
        capacity: 12,
        status: 'operational',
        plate: 'DS41 CXP',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 9,
        name: 'Mercedes-Benz Atego 1218',
        capacity: 12,
        status: 'operational',
        plate: 'NH65 QWD',
        maintenanceFrom: '2026-11-02T00:00',
        maintenanceTo: '2026-11-04T00:00',
      },
      {
        id: '9-actual',
        name: '',
        capacity: 12,
        status: 'operational',
        plate: 'NH65 QWD',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-02T00:00',
        maintenanceTo: '2026-11-04T00:00',
      },
    ],
  },
  {
    id: 'cap4',
    name: '20 tons capacity',
    eventCreation: false,
    children: [
      { id: 10, name: 'Mercedes Actros 2545', capacity: 20, status: 'operational', plate: 'KT19 LNV' },
      {
        id: '10-actual',
        name: '',
        capacity: 20,
        status: 'operational',
        plate: 'KT19 LNV',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 11, name: 'DAF XF 530', capacity: 20, status: 'operational', plate: 'WP64 GBX' },
      {
        id: '11-actual',
        name: '',
        capacity: 20,
        status: 'operational',
        plate: 'WP64 GBX',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 12,
        name: 'Renault T High 520',
        capacity: 20,
        status: 'operational',
        plate: 'CD70 UJE',
        maintenanceFrom: '2026-11-07T00:00',
        maintenanceTo: '2026-11-10T00:00',
      },
      {
        id: '12-actual',
        name: '',
        capacity: 20,
        status: 'operational',
        plate: 'CD70 UJE',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-07T00:00',
        maintenanceTo: '2026-11-10T00:00',
      },
    ],
  },
  {
    id: 'cap5',
    name: '24 tons capacity',
    eventCreation: false,
    children: [
      { id: 13, name: 'Volvo FH16', capacity: 24, status: 'operational', plate: 'BD67 HTA' },
      {
        id: '13-actual',
        name: '',
        capacity: 24,
        status: 'operational',
        plate: 'BD67 HTA',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 14, name: 'Freightliner Cascadia', capacity: 24, status: 'operational', plate: 'VA18 RQW' },
      {
        id: '14-actual',
        name: '',
        capacity: 24,
        status: 'operational',
        plate: 'VA18 RQW',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 15, name: 'Kenworth T680', capacity: 24, status: 'operational', plate: 'HY22 BPL' },
      {
        id: '15-actual',
        name: '',
        capacity: 24,
        status: 'operational',
        plate: 'HY22 BPL',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
    ],
  },
];

function initEvents() {
  return [
    {
      resource: 1,
      from: '100 Main St, Dallas, TX',
      to: '50 Congress Ave, Tulsa, OK',
      size: 3,
      pickup: ['2026-11-02T08:00', '2026-11-02T10:00'],
      drop: ['2026-11-02T12:00', '2026-11-02T14:00'],
      status: 'completed',
    },
    {
      resource: 2,
      from: '200 Elm St, Fargo, ND',
      to: '75 N 3rd St, Bismarck, ND',
      size: 2.5,
      pickup: ['2026-11-03T09:00', '2026-11-03T11:00'],
      drop: ['2026-11-03T13:00', '2026-11-03T15:00'],
      status: 'in progress',
    },
    {
      resource: 3,
      from: '150 Broadway, Manchester, NH',
      to: '50 Kennedy Memorial Dr, Waterville, ME',
      size: 3.5,
      pickup: ['2026-11-06T07:00', '2026-11-06T09:00'],
      drop: ['2026-11-06T12:00', '2026-11-06T14:00'],
      status: 'scheduled',
    },
    {
      resource: 1,
      from: '50 N Main St, Phoenix, AZ',
      to: '200 Broadway Ave, Albuquerque, NM',
      size: 3,
      pickup: ['2026-11-06T10:00', '2026-11-06T12:00'],
      drop: ['2026-11-06T15:00', '2026-11-06T17:00'],
      status: 'scheduled',
    },
    {
      resource: 4,
      from: '85 Arch St, Boston, MA',
      to: '40 Middle St, Portland, ME',
      size: 6.5,
      pickup: ['2026-11-02T08:00', '2026-11-02T10:00'],
      drop: ['2026-11-02T12:00', '2026-11-02T14:00'],
      status: 'completed',
    },
    {
      resource: 5,
      from: '300 E Main St, Richmond, VA',
      to: '100 S Tryon St, Charlotte, NC',
      size: 7,
      pickup: ['2026-11-03T05:00', '2026-11-03T07:00'],
      drop: ['2026-11-03T10:00', '2026-11-03T12:00'],
      status: 'completed',
    },
    {
      resource: 6,
      from: '900 W Belmont Ave, Chicago, IL',
      to: '300 Market St, St Louis, MO',
      size: 6,
      pickup: ['2026-11-09T09:00', '2026-11-09T11:00'],
      drop: ['2026-11-09T14:00', '2026-11-09T16:00'],
      status: 'scheduled',
    },
    {
      resource: 4,
      from: '250 King St, Greensboro, NC',
      to: '200 Hay St, Fayetteville, NC',
      size: 6.5,
      pickup: ['2026-11-03T13:00', '2026-11-03T15:00'],
      drop: ['2026-11-03T18:00', '2026-11-03T20:00'],
      status: 'scheduled',
    },
    {
      resource: 7,
      from: '233 S Wacker Dr, Chicago, IL',
      to: '10 Public Square, Cleveland, OH',
      size: 11,
      pickup: ['2026-11-03T08:00', '2026-11-03T11:00'],
      drop: ['2026-11-03T16:00', '2026-11-03T19:00'],
      status: 'scheduled',
    },
    {
      resource: 8,
      from: '180 S High St, Columbus, OH',
      to: '75 E Main St, Lexington, KY',
      size: 12,
      pickup: ['2026-11-05T07:00', '2026-11-05T10:00'],
      drop: ['2026-11-05T15:00', '2026-11-05T18:00'],
      status: 'scheduled',
    },
    {
      resource: 9,
      from: '100 E Capitol St, Jackson, MS',
      to: '250 Riverfront Pkwy, Chattanooga, TN',
      size: 10,
      pickup: ['2026-11-08T07:00', '2026-11-08T10:00'],
      drop: ['2026-11-08T16:00', '2026-11-08T19:00'],
      status: 'scheduled',
    },
    {
      resource: 7,
      from: '50 Arch St, Boston, MA',
      to: '200 Middle St, Portland, ME',
      size: 9,
      pickup: ['2026-11-09T08:00', '2026-11-09T10:00'],
      drop: ['2026-11-09T12:00', '2026-11-09T14:00'],
      status: 'scheduled',
    },
    {
      resource: 10,
      from: '50 N 1st Ave, Phoenix, AZ',
      to: '200 Broadway Ave, Albuquerque, NM',
      size: 20,
      pickup: ['2026-11-03T10:00', '2026-11-03T13:00'],
      drop: ['2026-11-03T18:00', '2026-11-03T21:00'],
      status: 'in progress',
    },
    {
      resource: 11,
      from: '120 Broadway, New York, NY',
      to: '55 Canal St, New Orleans, LA',
      size: 18,
      pickup: ['2026-11-06T06:00', '2026-11-06T09:00'],
      drop: ['2026-11-06T10:00', '2026-11-06T14:00'],
      status: 'scheduled',
    },
    {
      resource: 12,
      from: '600 Poydras St, New Orleans, LA',
      to: '200 Commerce St, Montgomery, AL',
      size: 20,
      pickup: ['2026-11-10T06:00', '2026-11-10T09:00'],
      drop: ['2026-11-10T14:00', '2026-11-10T17:00'],
      status: 'scheduled',
    },
    {
      resource: 10,
      from: '120 Main St, Boston, MA',
      to: '50 Canal St, New Orleans, LA',
      size: 19,
      pickup: ['2026-11-04T07:00', '2026-11-04T10:00'],
      drop: ['2026-11-04T14:00', '2026-11-04T17:00'],
      status: 'scheduled',
    },
    {
      resource: 13,
      from: '100 Main St, Dallas, TX',
      to: '15 Broadway St, Denver, CO',
      size: 22,
      pickup: ['2026-11-03T07:00', '2026-11-03T10:00'],
      drop: ['2026-11-04T04:00', '2026-11-04T08:00'],
      status: 'scheduled',
    },
    {
      resource: 14,
      from: '300 Main St, Houston, TX',
      to: '120 W Capitol Ave, Little Rock, AR',
      size: 24,
      pickup: ['2026-11-05T10:00', '2026-11-05T13:00'],
      drop: ['2026-11-05T18:00', '2026-11-05T22:00'],
      status: 'scheduled',
    },
    {
      resource: 15,
      from: '900 S Flower St, Los Angeles, CA',
      to: '300 2nd Ave, Seattle, WA',
      size: 23,
      pickup: ['2026-11-08T05:00', '2026-11-08T08:00'],
      drop: ['2026-11-08T12:00', '2026-11-08T16:00'],
      status: 'scheduled',
    },
    {
      resource: 13,
      from: '350 S Spring St, Los Angeles, CA',
      to: '500 E Rim Rd, El Paso, TX',
      size: 24,
      pickup: ['2026-11-11T06:00', '2026-11-11T09:00'],
      drop: ['2026-11-11T18:00', '2026-11-11T21:00'],
      status: 'scheduled',
    },
    {
      resource: 14,
      from: '200 Market St, Philadelphia, PA',
      to: '400 Washington Ave, Minneapolis, MN',
      size: 23,
      pickup: ['2026-11-06T13:00', '2026-11-06T16:00'],
      drop: ['2026-11-06T20:00', '2026-11-06T23:00'],
      status: 'scheduled',
    },
    {
      resource: 15,
      from: '500 S Wacker Dr, Chicago, IL',
      to: '100 State St, Portland, ME',
      size: 24,
      pickup: ['2026-11-09T08:00', '2026-11-09T11:00'],
      drop: ['2026-11-09T16:00', '2026-11-09T19:00'],
      status: 'scheduled',
    },
    {
      resource: 1,
      from: '300 W Madison St, Chicago, IL',
      to: '500 Euclid Ave, Cleveland, OH',
      size: 3,
      pickup: ['2026-11-04T08:00', '2026-11-04T10:00'],
      drop: ['2026-11-04T13:00', '2026-11-04T16:00'],
      status: 'scheduled',
    },
    {
      resource: 4,
      from: '800 N 3rd St, Philadelphia, PA',
      to: '200 W Baltimore St, Baltimore, MD',
      size: 6.5,
      pickup: ['2026-11-05T07:00', '2026-11-05T09:00'],
      drop: ['2026-11-05T12:00', '2026-11-05T15:00'],
      status: 'scheduled',
    },
    {
      resource: 5,
      from: '900 Biscayne Blvd, Miami, FL',
      to: '400 N Orange Ave, Orlando, FL',
      size: 7,
      pickup: ['2026-11-04T09:00', '2026-11-04T11:00'],
      drop: ['2026-11-04T14:00', '2026-11-04T18:00'],
      status: 'scheduled',
    },
    {
      resource: 7,
      from: '100 S 5th St, Minneapolis, MN',
      to: '300 W Kellogg Blvd, St Paul, MN',
      size: 9,
      pickup: ['2026-11-06T08:00', '2026-11-06T10:00'],
      drop: ['2026-11-06T14:00', '2026-11-06T17:00'],
      status: 'scheduled',
    },
    {
      resource: 8,
      from: '500 W 2nd St, Austin, TX',
      to: '2200 Post Oak Blvd, Houston, TX',
      size: 12,
      pickup: ['2026-11-07T07:00', '2026-11-07T10:00'],
      drop: ['2026-11-07T15:00', '2026-11-07T18:00'],
      status: 'scheduled',
    },
    {
      resource: 11,
      from: '600 Grant St, Pittsburgh, PA',
      to: '200 W Washington St, Indianapolis, IN',
      size: 18,
      pickup: ['2026-11-04T06:00', '2026-11-04T09:00'],
      drop: ['2026-11-04T16:00', '2026-11-04T20:00'],
      status: 'scheduled',
    },
    {
      resource: 14,
      from: '400 N Michigan Ave, Chicago, IL',
      to: '250 E Wisconsin Ave, Milwaukee, WI',
      size: 23,
      pickup: ['2026-11-08T08:00', '2026-11-08T11:00'],
      drop: ['2026-11-08T15:00', '2026-11-08T18:00'],
      status: 'scheduled',
    },
    {
      resource: 13,
      from: '700 Figueroa St, Los Angeles, CA',
      to: '50 W San Fernando St, San Jose, CA',
      size: 22,
      pickup: ['2026-11-07T06:00', '2026-11-07T09:00'],
      drop: ['2026-11-07T17:00', '2026-11-07T21:00'],
      status: 'scheduled',
    },
  ].map((e) => ({ ...e, start: e.pickup[0], end: e.drop[0], title: e.from + ' → ' + e.to }));
}

const allExternalEvents = [
  {
    id: 1,
    from: '2000 Q St, Sacramento, CA',
    to: '500 Poydras St, New Orleans, LA',
    size: 9,
    pickup: ['2026-11-03T09:00', '2026-11-03T11:00'],
    drop: ['2026-11-03T12:00', '2026-11-03T17:00'],
  },
  {
    id: 2,
    from: '100 State St, Madison, WI',
    to: '300 S Wacker Dr, Chicago, IL',
    size: 22,
    pickup: ['2026-11-03T13:00', '2026-11-03T15:00'],
    drop: ['2026-11-03T16:00', '2026-11-03T21:00'],
  },
  {
    id: 3,
    from: '1500 Market St, Philadelphia, PA',
    to: '600 Liberty St, Pittsburgh, PA',
    size: 13,
    pickup: ['2026-11-03T13:00', '2026-11-03T17:00'],
    drop: ['2026-11-03T18:00', '2026-11-03T22:00'],
  },
  {
    id: 25,
    from: '350 Fifth Ave, New York, NY',
    to: '100 Broad St, Newark, NJ',
    size: 3.5,
    pickup: ['2026-11-03T14:00', '2026-11-03T16:00'],
    drop: ['2026-11-03T18:00', '2026-11-03T21:00'],
  },
  {
    id: 26,
    from: '800 Market St, San Francisco, CA',
    to: '400 Capitol Mall, Sacramento, CA',
    size: 6,
    pickup: ['2026-11-03T15:00', '2026-11-03T17:00'],
    drop: ['2026-11-03T19:00', '2026-11-03T22:00'],
  },
  {
    id: 4,
    from: '1100 Congress Ave, Austin, TX',
    to: '500 E Main St, Dallas, TX',
    size: 20,
    pickup: ['2026-11-04T07:00', '2026-11-04T09:00'],
    drop: ['2026-11-04T10:00', '2026-11-04T15:00'],
  },
  {
    id: 5,
    from: '200 Peachtree St NW, Atlanta, GA',
    to: '50 Music Sq E, Nashville, TN',
    size: 8,
    pickup: ['2026-11-04T08:00', '2026-11-04T10:00'],
    drop: ['2026-11-04T12:00', '2026-11-04T16:00'],
  },
  {
    id: 6,
    from: '500 Boylston St, Boston, MA',
    to: '200 State St, Portland, ME',
    size: 3.5,
    pickup: ['2026-11-04T08:30', '2026-11-04T10:30'],
    drop: ['2026-11-04T12:30', '2026-11-04T18:30'],
  },
  {
    id: 27,
    from: '200 S Lamar Blvd, Austin, TX',
    to: '500 Commerce St, Dallas, TX',
    size: 19,
    pickup: ['2026-11-04T13:00', '2026-11-04T15:00'],
    drop: ['2026-11-04T18:00', '2026-11-04T22:00'],
  },
  {
    id: 28,
    from: '300 W Colfax Ave, Denver, CO',
    to: '100 N Main St, Pueblo, CO',
    size: 10,
    pickup: ['2026-11-05T10:00', '2026-11-05T12:00'],
    drop: ['2026-11-05T14:00', '2026-11-05T18:00'],
  },
  {
    id: 7,
    from: '400 S 4th St, Louisville, KY',
    to: '1400 Main St, Cincinnati, OH',
    size: 7,
    pickup: ['2026-11-05T06:00', '2026-11-05T08:00'],
    drop: ['2026-11-05T09:00', '2026-11-05T12:00'],
  },
  {
    id: 8,
    from: '410 S Houston Ave, Tulsa, OK',
    to: '250 W Capitol Ave, Little Rock, AR',
    size: 11.5,
    pickup: ['2026-11-05T06:30', '2026-11-05T08:30'],
    drop: ['2026-11-05T10:30', '2026-11-05T15:30'],
  },
  {
    id: 29,
    from: '450 Sutter St, San Francisco, CA',
    to: '200 Pine Ave, Long Beach, CA',
    size: 22,
    pickup: ['2026-11-06T07:00', '2026-11-06T09:00'],
    drop: ['2026-11-06T13:00', '2026-11-06T17:00'],
  },
  {
    id: 9,
    from: '999 3rd Ave, Seattle, WA',
    to: '500 W 8th St, Vancouver, WA',
    size: 3,
    pickup: ['2026-11-06T09:00', '2026-11-06T11:00'],
    drop: ['2026-11-06T12:00', '2026-11-06T17:00'],
  },
  {
    id: 10,
    from: '50 S Main St, Salt Lake City, UT',
    to: '300 N Broadway, Denver, CO',
    size: 5,
    pickup: ['2026-11-06T10:00', '2026-11-06T12:00'],
    drop: ['2026-11-06T13:00', '2026-11-06T18:00'],
  },
  {
    id: 11,
    from: '300 S Grand Ave, Los Angeles, CA',
    to: '400 E Van Buren St, Phoenix, AZ',
    size: 5,
    pickup: ['2026-11-10T08:00', '2026-11-10T10:00'],
    drop: ['2026-11-10T13:00', '2026-11-10T18:00'],
  },
  {
    id: 12,
    from: '200 W Washington St, Indianapolis, IN',
    to: '100 N Main St, Louisville, KY',
    size: 18,
    pickup: ['2026-11-11T07:00', '2026-11-11T09:00'],
    drop: ['2026-11-11T12:00', '2026-11-11T17:00'],
  },
  {
    id: 13,
    from: '500 N Michigan Ave, Chicago, IL',
    to: '250 Marquette Ave, Minneapolis, MN',
    size: 3,
    pickup: ['2026-11-12T09:00', '2026-11-12T11:00'],
    drop: ['2026-11-12T14:00', '2026-11-12T19:00'],
  },
  {
    id: 14,
    from: '600 Congress Ave, Austin, TX',
    to: '700 Texas Ave, Houston, TX',
    size: 12,
    pickup: ['2026-11-13T06:00', '2026-11-13T08:00'],
    drop: ['2026-11-13T11:00', '2026-11-13T16:00'],
  },
  {
    id: 15,
    from: '100 Broad St, Newark, NJ',
    to: '200 S Orange Ave, Orlando, FL',
    size: 7,
    pickup: ['2026-11-15T10:00', '2026-11-15T12:00'],
    drop: ['2026-11-15T15:00', '2026-11-15T20:00'],
  },
  {
    id: 16,
    from: '400 Pike St, Seattle, WA',
    to: '800 SW Broadway, Portland, OR',
    size: 9,
    pickup: ['2026-11-17T07:00', '2026-11-17T09:00'],
    drop: ['2026-11-17T12:00', '2026-11-17T16:00'],
  },
  {
    id: 17,
    from: '300 Main St, Buffalo, NY',
    to: '500 Euclid Ave, Cleveland, OH',
    size: 22,
    pickup: ['2026-11-18T08:00', '2026-11-18T11:00'],
    drop: ['2026-11-18T14:00', '2026-11-18T19:00'],
  },
  {
    id: 18,
    from: '150 S Miami Ave, Miami, FL',
    to: '300 Peachtree St, Atlanta, GA',
    size: 6,
    pickup: ['2026-11-19T09:00', '2026-11-19T11:00'],
    drop: ['2026-11-19T13:00', '2026-11-19T18:00'],
  },
  {
    id: 19,
    from: '700 W 3rd St, Kansas City, MO',
    to: '400 N Broadway, St Louis, MO',
    size: 15,
    pickup: ['2026-11-21T06:00', '2026-11-21T08:00'],
    drop: ['2026-11-21T10:00', '2026-11-21T15:00'],
  },
  {
    id: 20,
    from: '250 E Houston St, San Antonio, TX',
    to: '1200 Commerce St, Dallas, TX',
    size: 4,
    pickup: ['2026-11-22T11:00', '2026-11-22T13:00'],
    drop: ['2026-11-22T15:00', '2026-11-22T20:00'],
  },
  {
    id: 21,
    from: '800 N Michigan Ave, Chicago, IL',
    to: '500 Woodward Ave, Detroit, MI',
    size: 11,
    pickup: ['2026-11-24T07:00', '2026-11-24T09:00'],
    drop: ['2026-11-24T12:00', '2026-11-24T17:00'],
  },
  {
    id: 22,
    from: '900 Market St, San Francisco, CA',
    to: '600 Wilshire Blvd, Los Angeles, CA',
    size: 20,
    pickup: ['2026-11-25T08:00', '2026-11-25T10:00'],
    drop: ['2026-11-25T14:00', '2026-11-25T19:00'],
  },
  {
    id: 23,
    from: '350 5th Ave, New York, NY',
    to: '100 Federal St, Boston, MA',
    size: 3.5,
    pickup: ['2026-11-26T09:00', '2026-11-26T11:00'],
    drop: ['2026-11-26T13:00', '2026-11-26T17:00'],
  },
  {
    id: 24,
    from: '200 S Tryon St, Charlotte, NC',
    to: '100 N Main St, Greenville, SC',
    size: 8,
    pickup: ['2026-11-28T06:00', '2026-11-28T08:00'],
    drop: ['2026-11-28T10:00', '2026-11-28T14:00'],
  },
];

function initExternalEvents() {
  const events = allExternalEvents.map((e) => ({ ...e }));
  events.forEach((ev) => {
    ev.start = ev.start || ev.pickup[0];
    ev.end = ev.end || ev.drop[0];
    ev.title = ev.from + ' → ' + ev.to;
  });
  return events;
}

function getResourceMaintenanceStatus(resource, now, currentViewStart, currentViewEnd) {
  if (!resource.maintenanceFrom || !resource.maintenanceTo) return 'operational';
  const from = new Date(resource.maintenanceFrom);
  const to = new Date(resource.maintenanceTo);
  if (now >= from && now <= to) return 'in-maintenance';
  if (from < currentViewEnd && to > currentViewStart) return 'maintenance-planned';
  return 'operational';
}

function buildViewConfig(days) {
  return {
    timeline: {
      type: 'day',
      size: days,
      timeCellStep: 60,
      timeLabelStep: 60,
      eventHeight: 'variable',
      zoomLevels: {
        1: { type: 'day', size: days, columnWidth: 'xsmall', timeCellStep: 240, timeLabelStep: 240 },
        2: { type: 'day', size: days, columnWidth: 'small', timeCellStep: 120, timeLabelStep: 120 },
        3: { type: 'day', size: days, columnWidth: 'medium', timeCellStep: 60, timeLabelStep: 60 },
        4: { type: 'day', size: days, columnWidth: 'xlarge', timeCellStep: 30, timeLabelStep: 60 },
        5: { type: 'day', size: days, columnWidth: 'xlarge', timeCellStep: 15, timeLabelStep: 60 },
      },
    },
  };
}

function JobCard({ job }) {
  const [elem, setElem] = useState(null);
  return (
    <div ref={setElem} className="mds-dispatch-jobs mbsc-flex">
      <div className="mds-dispatch-job-route">
        <div className="mds-dispatch-job-stop mbsc-flex">
          <span className="mds-dispatch-job-dot mds-dispatch-job-dot-origin"></span>
          <span className="mds-dispatch-job-addr">{job.from}</span>
        </div>
        <div className="mds-dispatch-job-connector"></div>
        <div className="mds-dispatch-job-stop mbsc-flex">
          <span className="mds-dispatch-job-dot mds-dispatch-job-dot-dest"></span>
          <span className="mds-dispatch-job-addr">{job.to}</span>
        </div>
      </div>
      <div className="mds-dispatch-job-meta mbsc-flex-col mbsc-flex-none">
        <span className="mds-dispatch-job-time">
          {formatDate('H:mm', new Date(job.pickup[0]))} – {formatDate('H:mm', new Date(job.drop[1]))}
        </span>
        <span className="mds-dispatch-job-size">{job.size} t</span>
      </div>
      <Draggable dragData={job} element={elem} />
    </div>
  );
}

JobCard.propTypes = {
  job: PropTypes.shape({
    drop: PropTypes.array,
    from: PropTypes.string,
    pickup: PropTypes.array,
    size: PropTypes.number,
    to: PropTypes.string,
  }).isRequired,
};

function App() {
  const now = new Date();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  const nowRef = useRef(now);
  const todayRef = useRef(today);
  const searchQueryRef = useRef('');
  const calendarRef = useRef(null);
  const myEventsRef = useRef(null);
  if (!myEventsRef.current) myEventsRef.current = initEvents();
  const externalEventsRef = useRef(null);
  if (!externalEventsRef.current) externalEventsRef.current = initExternalEvents();
  const scheduledJobIdsRef = useRef([]);
  const myInvalidsRef = useRef([]);
  const currentViewStartRef = useRef(today);
  const currentViewEndRef = useRef(new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000));
  const currentRangeStartRef = useRef(today);
  const currentRangeDaysRef = useRef(7);
  const pendingRangeStartRef = useRef(today);
  const pendingRangeDaysRef = useRef(7);
  const filterPopupRef = useRef(null);
  const rangePopupRef = useRef(null);
  const [filterAnchor, setFilterAnchor] = useState(null);

  const [myEvents, setMyEvents] = useState(initEvents);
  const [filteredResources, setFilteredResources] = useState([]);
  const [maintenanceInvalids, setMaintenanceInvalids] = useState([]);
  const [calendarInvalid, setCalendarInvalid] = useState([]);
  const [zoomLevel, setZoomLevel] = useState(3);
  const [calView, setCalView] = useState(() => buildViewConfig(7));
  const [refDate, setRefDate] = useState(today);
  const [jobListItems, setJobListItems] = useState([]);
  const [filters, setFilters] = useState(() => {
    const f = { operational: true, maintenance: true };
    allResources.forEach((cat) => {
      f[cat.id] = true;
    });
    return f;
  });
  const [tempFilters, setTempFilters] = useState({});
  const [statusFilters, setStatusFilters] = useState(['scheduled', 'in progress', 'completed']);
  const [searchQuery, setSearchQuery] = useState('');
  const [rangeLabel, setRangeLabel] = useState('');
  const [rangeSelectValue, setRangeSelectValue] = useState('7');
  const [rangeInputsDisabled, setRangeInputsDisabled] = useState(true);
  const [calendarRangeValue, setCalendarRangeValue] = useState(null);
  const [rangeStartInput, rangeStartRef] = useState(null);
  const [rangeEndInput, rangeEndRef] = useState(null);
  const [isToastOpen, setIsToastOpen] = useState(false);
  const [toastMessage, setToastMessage] = useState('');

  const showToast = useCallback((msg) => {
    setToastMessage(msg);
    setIsToastOpen(true);
  }, []);

  const computeFilteredResources = useCallback(
    (currentFilters, currentSearchQuery) => {
      const f = currentFilters || filters;
      const sq = currentSearchQuery !== undefined ? currentSearchQuery : searchQueryRef.current;
      return allResources
        .map((category) => {
          const keptIds = {};
          category.children.forEach((resource) => {
            if (String(resource.id).includes('actual')) return;
            const filterKey =
              getResourceMaintenanceStatus(resource, nowRef.current, currentViewStartRef.current, currentViewEndRef.current) ===
              'operational'
                ? 'operational'
                : 'maintenance';
            if (
              f[filterKey] &&
              (!sq || resource.name.toLowerCase().includes(sq.toLowerCase()) || resource.plate.toLowerCase().includes(sq.toLowerCase()))
            ) {
              keptIds[resource.id] = true;
            }
          });
          return {
            id: category.id,
            name: category.name,
            eventCreation: category.eventCreation,
            children: category.children.filter((resource) => {
              if (String(resource.id).includes('actual')) {
                const parentId = String(resource.id).replace('-actual', '');
                if (!keptIds[parentId]) return false;
                const parent = category.children.find((r) => String(r.id) === parentId);
                return (
                  !parent ||
                  getResourceMaintenanceStatus(parent, nowRef.current, currentViewStartRef.current, currentViewEndRef.current) ===
                    'operational'
                );
              }
              return keptIds[resource.id];
            }),
          };
        })
        .filter((res) => res.children.length > 0 && f[res.id]);
    },
    [filters, nowRef, currentViewStartRef, currentViewEndRef],
  );

  const getEffectiveStatus = useCallback((ev) => {
    if (ev.status === 'actual') return ev.title === 'Completed' ? 'completed' : 'in progress';
    return ev.status || 'scheduled';
  }, []);

  const getStatusFilteredEvents = useCallback(
    (events, currentStatusFilters) => {
      const sf = currentStatusFilters || statusFilters;
      return events.filter((ev) => sf.includes(getEffectiveStatus(ev)));
    },
    [statusFilters, getEffectiveStatus],
  );

  const getMaxAvailableCapacity = useCallback(
    (currentFilters) => {
      const f = currentFilters || filters;
      let max = 0;
      allResources.forEach((group) => {
        if (f[group.id]) {
          group.children.forEach((child) => {
            if (!String(child.id).includes('actual') && child.capacity > max) max = child.capacity;
          });
        }
      });
      return max;
    },
    [filters],
  );

  const buildMaintenanceInvalids = useCallback(() => {
    const invalids = [];
    allResources.forEach((group) => {
      group.children.forEach((resource) => {
        if (resource.maintenanceFrom && resource.maintenanceTo && !String(resource.id).includes('actual')) {
          invalids.push({
            start: resource.maintenanceFrom,
            end: resource.maintenanceTo,
            resource: resource.id,
            cssClass: 'mds-dispatch-maintenance-invalid',
          });
        }
      });
    });
    return invalids;
  }, []);

  const refreshJobList = useCallback(
    (currentFilters, currentScheduledIds, rangeStart, rangeDays) => {
      const now = nowRef.current;
      const rStart = rangeStart || currentRangeStartRef.current;
      const rDays = rangeDays || currentRangeDaysRef.current;
      const rangeEnd = new Date(rStart.getTime() + rDays * 24 * 60 * 60 * 1000);
      const minPickup = new Date(now.getTime() + 2 * 60 * 60 * 1000);
      const maxCapacity = getMaxAvailableCapacity(currentFilters);
      const sIds = currentScheduledIds || scheduledJobIdsRef.current;
      const visible = externalEventsRef.current.filter((job) => {
        if (sIds.indexOf(job.id) !== -1) return false;
        if (job.size > maxCapacity) return false;
        const pickupStart = new Date(job.pickup[0]);
        if (pickupStart < minPickup) return false;
        return pickupStart >= rStart && pickupStart < rangeEnd;
      });
      const items = [];
      let lastDayKey = null;
      for (const job of visible) {
        const pickupDate = new Date(job.pickup[0]);
        const dayKey = formatDate('YYYY-MM-DD', pickupDate);
        if (dayKey !== lastDayKey) {
          items.push({ type: 'header', label: formatDate('DDD, DD MMM', pickupDate), key: 'hdr-' + dayKey });
          lastDayKey = dayKey;
        }
        items.push({ type: 'job', job, key: 'job-' + job.id });
      }
      setJobListItems(items);
    },
    [getMaxAvailableCapacity, nowRef, currentRangeStartRef, currentRangeDaysRef, scheduledJobIdsRef, externalEventsRef],
  );

  const addActualEvents = useCallback(
    (events) => {
      const now = nowRef.current;
      const possibleOffsets = [-120, -90, -60, -30, -15, 0, 15, 30, 60, 90, 120];
      for (let i = 0; i < events.length; ++i) {
        const event = events[i];
        event.start = event.start ? new Date(event.start) : event.start;
        event.end = event.end ? new Date(event.end) : event.end;
        if (event.status === 'actual') continue;
        if (event.end <= now) event.color = statusColors['completed'];
        else if (event.start < now) event.color = statusColors['in progress'];
        else event.color = statusColors[event.status] || statusColors['scheduled'];
        event.editable = now < event.start;
        if (event.start < now && event.end > now) {
          if (!event.actual) {
            const dOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            const aStart = new Date(event.start.getTime() + dOff);
            const actualStart = aStart >= event.start && aStart < now ? aStart : event.start;
            const newEvent = {
              resource: event.resource + '-actual',
              from: event.from,
              to: event.to,
              pickup: event.pickup,
              drop: event.drop,
              size: event.size,
              start: actualStart,
              end: now,
              title: 'In progress',
              status: 'actual',
              color: event.color,
              cssClass: 'mds-dispatch-actual-event mds-dispatch-pulse',
              editable: false,
            };
            event.actual = true;
            event.actualRef = newEvent;
            events.push(newEvent);
          } else if (event.actualRef) {
            event.actualRef.end = now;
          }
        } else if (event.end <= now) {
          if (!event.actual) {
            const dOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            const aOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            let cAStart = new Date(event.start.getTime() + dOff);
            if (!(cAStart >= event.start && cAStart < event.end)) cAStart = event.start;
            let cAEnd = new Date(event.drop[0] instanceof Date ? event.drop[0].getTime() + aOff : new Date(event.drop[0]).getTime() + aOff);
            const minEnd = new Date(cAStart.getTime() + 30 * 60000);
            if (cAEnd < minEnd) cAEnd = minEnd;
            const cNewEvent = {
              resource: event.resource + '-actual',
              from: event.from,
              to: event.to,
              pickup: event.pickup,
              drop: event.drop,
              size: event.size,
              start: cAStart,
              end: cAEnd,
              title: 'Completed',
              status: 'actual',
              color: event.color,
              cssClass: 'mds-dispatch-actual-event',
              editable: false,
            };
            event.actual = true;
            event.actualRef = cNewEvent;
            events.push(cNewEvent);
          } else if (event.actualRef && event.actualRef.title !== 'Completed') {
            const aOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            const drop0 = event.drop[0] instanceof Date ? event.drop[0].getTime() : new Date(event.drop[0]).getTime();
            let cAEnd = new Date(drop0 + aOff);
            const minEnd = new Date(event.actualRef.start.getTime() + 30 * 60000);
            if (cAEnd < minEnd) cAEnd = minEnd;
            event.actualRef.end = cAEnd;
            event.actualRef.color = event.color;
            event.actualRef.cssClass = 'mds-dispatch-actual-event';
            event.actualRef.title = 'Completed';
          }
        }
      }
    },
    [nowRef],
  );

  const findResourceById = useCallback((id) => {
    const baseId = String(id).replace('-actual', '');
    for (const group of allResources) {
      for (const child of group.children) {
        if (String(child.id) === baseId) return child;
      }
    }
    return null;
  }, []);

  const findFirstSlot = useCallback(
    (draggedEvent) => {
      const now = new Date();
      const minStart = new Date(now.getTime() + 2 * 60 * 60 * 1000);
      const windowStart = new Date(draggedEvent.pickup[0]);
      const windowEnd = new Date(draggedEvent.drop[1]);
      let eventStart = new Date(draggedEvent.start);
      let eventEnd = new Date(draggedEvent.end);
      const effectiveWindowStart = windowStart < minStart ? minStart : windowStart;
      const eventDuration = eventEnd - eventStart;
      const windowSize = windowEnd - effectiveWindowStart;
      if (eventDuration > windowSize) return null;
      if (eventStart < effectiveWindowStart) {
        eventStart = new Date(effectiveWindowStart);
        eventEnd = new Date(effectiveWindowStart.getTime() + eventDuration);
      }
      if (eventEnd > windowEnd) {
        eventEnd = new Date(windowEnd);
        eventStart = new Date(windowEnd.getTime() - eventDuration);
      }
      if (draggedEvent.resource) {
        const resource = findResourceById(draggedEvent.resource);
        if (resource?.maintenanceFrom && resource?.maintenanceTo) {
          const mFrom = new Date(resource.maintenanceFrom);
          const mTo = new Date(resource.maintenanceTo);
          if (eventStart < mTo && eventEnd > mFrom) {
            eventStart = new Date(mTo);
            eventEnd = new Date(mTo.getTime() + eventDuration);
            if (eventEnd > windowEnd) return null;
          }
        }
      }
      return { start: eventStart, end: eventEnd };
    },
    [findResourceById],
  );

  useEffect(() => {
    const today = todayRef.current;
    allResources.forEach((group) => {
      group.children.forEach((resource) => {
        if (resource.maintenanceFrom) resource.maintenanceFrom = new Date(resource.maintenanceFrom);
        if (resource.maintenanceTo) resource.maintenanceTo = new Date(resource.maintenanceTo);
      });
    });
    addActualEvents(myEventsRef.current);
    const mInvalids = buildMaintenanceInvalids();
    const fResources = computeFilteredResources(null, '');
    const end = new Date(today.getTime() + 6 * 24 * 60 * 60 * 1000);
    setMaintenanceInvalids(mInvalids);
    setCalendarInvalid(mInvalids);
    setFilteredResources(fResources);
    setMyEvents([...myEventsRef.current]);
    setCalendarRangeValue([today, end]);
    setRangeLabel(formatDate('MMM D', today) + ' – ' + formatDate('MMM D, YYYY', end));
    refreshJobList(null, []);
    const interval = setInterval(() => {
      nowRef.current = new Date();
      addActualEvents(myEventsRef.current);
      setMyEvents([...myEventsRef.current]);
    }, 60000);
    return () => clearInterval(interval);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const handleZoom = useCallback(
    (zoom) => {
      setZoomLevel(zoom);
      setCalView(buildViewConfig(currentRangeDaysRef.current));
    },
    [currentRangeDaysRef],
  );

  const applyDaysRange = useCallback(
    (days, startDate) => {
      const target = startDate || todayRef.current;
      currentViewStartRef.current = target;
      currentViewEndRef.current = new Date(target.getTime() + days * 24 * 60 * 60 * 1000);
      setRefDate(target);
      setCalView(buildViewConfig(days));
      const fResources = computeFilteredResources();
      setFilteredResources(fResources);
      const navigateTo = todayRef.current >= target && todayRef.current < currentViewEndRef.current ? nowRef.current : target;
      setTimeout(() => calendarRef.current?.navigate(navigateTo), 100);
    },
    [computeFilteredResources, todayRef, nowRef, currentViewStartRef, currentViewEndRef, calendarRef],
  );

  const applyPendingRange = useCallback(() => {
    currentRangeStartRef.current = pendingRangeStartRef.current;
    currentRangeDaysRef.current = pendingRangeDaysRef.current;
    applyDaysRange(currentRangeDaysRef.current, currentRangeStartRef.current);
    const start = currentRangeStartRef.current;
    const days = currentRangeDaysRef.current;
    const end = new Date(start.getTime() + (days - 1) * 24 * 60 * 60 * 1000);
    setRangeLabel(formatDate('MMM D', start) + ' – ' + formatDate('MMM D, YYYY', end));
    refreshJobList();
    rangePopupRef.current?.close();
  }, [applyDaysRange, refreshJobList]);

  const invalidateResources = useCallback(
    (event) => {
      const now = new Date();
      nowRef.current = now;
      const minTime = new Date(now.getTime() + 2 * 60 * 60 * 1000);
      const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
      const invalidIds = [];
      const validIds = [];
      const windowStart = new Date(event.pickup[0]);
      const windowEnd = new Date(event.drop[1]);
      const allCalendarEvents = calendarRef.current?.getEvents() || [];
      for (const group of allResources) {
        for (const truck of group.children) {
          let isValid = true;
          if (truck.capacity < event.size) {
            truck.eventCreation = false;
            invalidIds.push(truck.id);
            isValid = false;
          }
          if (isValid) {
            const truckEvents = allCalendarEvents.filter((ev) => ev.resource === truck.id);
            const overlappingEvent = truckEvents.find((ev) => {
              const evEnd = ev.drop ? new Date(ev.drop[1]) : ev.end;
              return evEnd > windowStart && ev.start < windowEnd;
            });
            if (overlappingEvent && event.resource !== truck.id) {
              truck.eventCreation = false;
              invalidIds.push(truck.id);
              if (!String(truck.id).includes('actual')) invalidIds.push(truck.id + '-actual');
              isValid = false;
            }
          }
          if (isValid && !String(truck.id).includes('actual')) validIds.push(truck.id);
        }
      }
      myInvalidsRef.current = [
        { start: todayRef.current, end: windowStart < minTime ? minTime : windowStart, cssClass: 'mds-dispatch-disabled-row' },
        {
          start: windowStart < minTime ? minTime : windowStart,
          end: windowEnd,
          resource: invalidIds,
          cssClass: 'mds-dispatch-disabled-row',
        },
        { start: windowEnd, end: sevenDaysFromNow, cssClass: 'mds-dispatch-disabled-row' },
      ];
      setCalendarInvalid([...myInvalidsRef.current, ...maintenanceInvalids]);
      return validIds.length > 0 ? validIds[0] : null;
    },
    [maintenanceInvalids, nowRef, todayRef, calendarRef, myInvalidsRef],
  );

  const resetEventCreationFlags = useCallback(() => {
    for (const group of allResources) {
      for (const truck of group.children) {
        if (!String(truck.id).includes('actual')) truck.eventCreation = true;
      }
    }
  }, []);

  const onEventCreated = useCallback(
    (args) => {
      if (args.action === 'externalDrop') {
        scheduledJobIdsRef.current = [...scheduledJobIdsRef.current, args.event.id];
        const scheduledEvent = { ...args.event, status: 'scheduled', color: statusColors['scheduled'] };
        calendarRef.current?.updateEvent(scheduledEvent);
        myEventsRef.current = [...myEventsRef.current, scheduledEvent];
        setMyEvents([...myEventsRef.current]);
        setJobListItems((prev) => prev.filter((item) => item.type !== 'job' || item.job.id !== args.event.id));
      }
      showToast(args.event.from + ' → ' + args.event.to + ' added');
    },
    [showToast],
  );

  const onEventCreateFailed = useCallback(
    (args) => {
      const draggedEvent = args.event;
      const slot = findFirstSlot(draggedEvent);
      if (slot) {
        draggedEvent.start = slot.start;
        draggedEvent.end = slot.end;
        calendarRef.current?.addEvent(draggedEvent);
        if (args.action === 'externalDrop') {
          scheduledJobIdsRef.current = [...scheduledJobIdsRef.current, args.event.id];
          setJobListItems((prev) => prev.filter((item) => item.type !== 'job' || item.job.id !== args.event.id));
        }
        setTimeout(() => calendarRef.current?.navigateToEvent({ start: draggedEvent.start, resource: draggedEvent.resource }));
        showToast(draggedEvent.from + ' → ' + draggedEvent.to + ' added to first available slot');
      } else {
        showToast('No available slot found for ' + args.event.from + ' → ' + args.event.to);
      }
    },
    [findFirstSlot, showToast],
  );

  const onEventUpdateFailed = useCallback(
    (args) => {
      const draggedEvent = args.event;
      const slot = findFirstSlot(draggedEvent);
      if (slot) {
        draggedEvent.start = slot.start;
        draggedEvent.end = slot.end;
        calendarRef.current?.updateEvent(draggedEvent);
        setTimeout(() => calendarRef.current?.navigateToEvent({ start: draggedEvent.start, resource: draggedEvent.resource }));
        showToast(draggedEvent.from + ' → ' + draggedEvent.to + ' moved to first available slot');
      } else {
        const originalEvent = args.oldEvent;
        setTimeout(() => calendarRef.current?.navigateToEvent({ start: originalEvent.start, resource: originalEvent.resource }));
        showToast('No available slot found for ' + args.event.from + ' → ' + args.event.to);
      }
    },
    [findFirstSlot, showToast],
  );

  const onEventDragStart = useCallback(
    (args) => {
      const event = args.event;
      const resourceToNavigate = invalidateResources(event);
      if (!event.resource) {
        const slot = findFirstSlot(event);
        if (resourceToNavigate && slot) {
          setTimeout(() => calendarRef.current?.navigateToEvent({ start: slot.start, resource: resourceToNavigate }));
        } else {
          showToast('No available slot for: ' + event.from + ' → ' + event.to);
        }
      }
    },
    [invalidateResources, findFirstSlot, showToast],
  );

  const onEventDragEnd = useCallback(() => {
    resetEventCreationFlags();
    myInvalidsRef.current = [];
    const fResources = computeFilteredResources();
    setFilteredResources(fResources);
    setCalendarInvalid([...maintenanceInvalids]);
  }, [resetEventCreationFlags, computeFilteredResources, maintenanceInvalids]);

  const onEventClick = useCallback(
    (args) => {
      if (args.event.status === 'actual') {
        const ev = args.event;
        const actualStart = new Date(ev.start);
        const scheduledDeparture = new Date(ev.pickup[0]);
        const departureDelay = Math.round((actualStart - scheduledDeparture) / 60000);
        const formatDelay = (min) => {
          if (Math.abs(min) < 5) return 'on time';
          if (min > 0) return min + ' min late';
          return Math.abs(min) + ' min early';
        };
        const formatDuration = (ms) => {
          const t = Math.round(ms / 60000);
          const h = Math.floor(t / 60);
          const m = t % 60;
          if (h === 0) return m + ' min';
          if (m === 0) return h + ' h';
          return h + ' h ' + m + ' min';
        };
        let msg;
        if (ev.title === 'In progress') {
          msg = 'Actual transport: departed ' + formatDelay(departureDelay) + ' · running ' + formatDuration(new Date() - actualStart);
        } else {
          const actualEnd = new Date(ev.end);
          const scheduledArrival = new Date(ev.drop[0]);
          const arrivalDelay = Math.round((actualEnd - scheduledArrival) / 60000);
          msg = 'Actual transport: departed ' + formatDelay(departureDelay) + ' · arrived ' + formatDelay(arrivalDelay);
        }
        showToast(msg);
      }
    },
    [showToast],
  );

  const onPageLoaded = useCallback(
    (args) => {
      currentViewStartRef.current = args.firstDay;
      currentViewEndRef.current = args.lastDay;
    },
    [currentViewStartRef, currentViewEndRef],
  );

  const renderResource = useCallback(
    (resource) => {
      const mStatus = getResourceMaintenanceStatus(resource, nowRef.current, currentViewStartRef.current, currentViewEndRef.current);
      return (
        <div>
          <div className="mds-dispatch-name">
            {resource.name}
            {resource.name && resource.plate && <span className="mds-dispatch-plate">{resource.plate}</span>}
          </div>
          {!resource.isParent && resource.name && (
            <div className="mds-dispatch-status">
              <span className="mds-dispatch-status-dot" style={{ backgroundColor: maintenanceColors[mStatus] }}></span>
              {maintenanceLabels[mStatus]}
            </div>
          )}
        </div>
      );
    },
    [nowRef, currentViewStartRef, currentViewEndRef],
  );

  const renderResourceEmpty = useCallback(
    () => (
      <div className="mds-dispatch-empty mbsc-flex mbsc-align-items-center">
        <div className="mbsc-flex-1-1">
          <img src="https://img.mobiscroll.com/demos/filter-no-result.png" alt="Empty list" style={{ width: 100 }} />
          <p className="mbsc-font mbsc-margin mbsc-medium mbsc-italic mbsc-txt-muted">No resources match your search.</p>
          <p className="mbsc-margin mbsc-medium mbsc-italic mbsc-txt-muted">Adjust your filters or try a different keyword.</p>
          <Button
            variant="outline"
            onClick={() => {
              setSearchQuery('');
              searchQueryRef.current = '';
              const newFilters = { operational: true, maintenance: true };
              allResources.forEach((cat) => {
                newFilters[cat.id] = true;
              });
              setFilters(newFilters);
              const fResources = computeFilteredResources(newFilters, '');
              setFilteredResources(fResources);
              refreshJobList(newFilters);
              showToast('Filters cleared');
            }}
          >
            Reset Filters
          </Button>
        </div>
      </div>
    ),
    [computeFilteredResources, refreshJobList, showToast],
  );

  const renderResourceHeader = useCallback(
    () => (
      <div className="mbsc-flex mbsc-align-items-center mbsc-font mds-dispatch-search">
        <Input
          className="mbsc-flex-1-1"
          type="text"
          inputStyle="outline"
          startIcon="material-search"
          autoComplete="off"
          placeholder="Search..."
          value={searchQuery}
          onChange={(e) => {
            const q = e.target.value.toLowerCase();
            setSearchQuery(q);
            searchQueryRef.current = q;
            setTimeout(() => {
              const fResources = computeFilteredResources(null, q);
              setFilteredResources(fResources);
            }, 300);
          }}
        />
        <Button
          startIcon="material-filter-list"
          variant="outline"
          className="mbsc-flex-none"
          onClick={(e) => {
            setTempFilters({ ...filters });
            setFilterAnchor(e.currentTarget);
            setTimeout(() => filterPopupRef.current?.open());
          }}
        >
          Filter
        </Button>
      </div>
    ),
    [searchQuery, filters, computeFilteredResources],
  );

  const renderTimelineEventContent = useCallback((data) => {
    const job = data.original;
    if (job.status === 'actual') {
      return job.title === 'In progress' ? <span className="mds-dispatch-actual-label">In progress</span> : null;
    }
    return (
      <div className="mds-dispatch-event-content-wrapper mbsc-flex">
        <span className="mds-dispatch-event-route mbsc-flex-1-1">
          {job.from} → {job.to}
        </span>
        <span className="mds-dispatch-event-planned-badge">
          <span className="mds-dispatch-event-planned-badge-text">Planned</span>
        </span>
      </div>
    );
  }, []);

  const calendarData = useMemo(() => getStatusFilteredEvents(myEvents, statusFilters), [myEvents, statusFilters, getStatusFilteredEvents]);

  const rangeSelectData = useMemo(
    () => [
      { value: 'custom', text: 'Custom' },
      { value: '7', text: 'Next 7 days' },
      { value: '14', text: 'Next 14 days' },
      { value: '30', text: 'Next 30 days' },
      { value: '60', text: 'Next 60 days' },
      { value: '90', text: 'Next 90 days' },
    ],
    [],
  );

  const rangePopupResponsive = useMemo(
    () => ({
      xsmall: {
        display: 'bottom',
        touchUi: true,
        buttons: [{ text: 'Apply', keyCode: 'enter', handler: applyPendingRange, cssClass: 'mbsc-popup-button-primary' }, 'cancel'],
      },
      custom: { breakpoint: 575, buttons: [], display: 'anchored', touchUi: false, scrollLock: false, maxWidth: 920 },
    }),
    [applyPendingRange],
  );

  const filterPopupButtons = useMemo(
    () => [
      'cancel',
      {
        text: 'Apply',
        keyCode: 'enter',
        cssClass: 'mbsc-popup-button-primary',
        handler: () => {
          setFilters({ ...tempFilters });
          const fResources = computeFilteredResources(tempFilters);
          setFilteredResources(fResources);
          refreshJobList(tempFilters);
          filterPopupRef.current?.close();
          showToast('Filters applied');
        },
      },
    ],
    [tempFilters, computeFilteredResources, refreshJobList, showToast],
  );

  return (
    <Page className="mds-dispatch-page">
      <div className="mds-dispatch-outer mbsc-flex-col">
        <div className="mds-dispatch-custom-header mbsc-flex">
          <Button variant="flat" onClick={() => rangePopupRef.current?.open()}>
            <span className="mds-dispatch-range-label">{rangeLabel}</span>
          </Button>
          <div className="mds-dispatch-header-right mbsc-flex">
            <SegmentedGroup cssClass="mds-dispatch-status-filter" select="multiple">
              <Segmented
                value="scheduled"
                checked={statusFilters.includes('scheduled')}
                onChange={(e) => {
                  if (e.target.checked) setStatusFilters((sf) => [...sf, 'scheduled']);
                  else setStatusFilters((sf) => sf.filter((s) => s !== 'scheduled'));
                }}
              >
                <span className="mds-dispatch-seg-dot mds-dispatch-seg-dot-scheduled"></span>Scheduled
              </Segmented>
              <Segmented
                value="in progress"
                checked={statusFilters.includes('in progress')}
                onChange={(e) => {
                  if (e.target.checked) setStatusFilters((sf) => [...sf, 'in progress']);
                  else setStatusFilters((sf) => sf.filter((s) => s !== 'in progress'));
                }}
              >
                <span className="mds-dispatch-seg-dot mds-dispatch-seg-dot-inprogress"></span>In progress
              </Segmented>
              <Segmented
                value="completed"
                checked={statusFilters.includes('completed')}
                onChange={(e) => {
                  if (e.target.checked) setStatusFilters((sf) => [...sf, 'completed']);
                  else setStatusFilters((sf) => sf.filter((s) => s !== 'completed'));
                }}
              >
                <span className="mds-dispatch-seg-dot mds-dispatch-seg-dot-completed"></span>Completed
              </Segmented>
            </SegmentedGroup>
            <div className="mds-dispatch-zoom mbsc-flex">
              <Button icon="minus" variant="flat" disabled={zoomLevel === 1} onClick={() => handleZoom(zoomLevel - 1)} />
              <input
                type="range"
                min="1"
                max="5"
                value={zoomLevel}
                className="mds-dispatch-zoom-slider"
                onChange={(e) => handleZoom(+e.target.value)}
              />
              <Button icon="plus" variant="flat" disabled={zoomLevel === 5} onClick={() => handleZoom(zoomLevel + 1)} />
            </div>
            <Button
              variant="outline"
              startIcon="clock"
              className="mds-dispatch-now-btn"
              onClick={() => {
                const now = new Date();
                nowRef.current = now;
                todayRef.current = new Date(now.getFullYear(), now.getMonth(), now.getDate());
                currentRangeStartRef.current = todayRef.current;
                applyDaysRange(currentRangeDaysRef.current, todayRef.current);
                const start = todayRef.current;
                const days = currentRangeDaysRef.current;
                const end = new Date(start.getTime() + (days - 1) * 24 * 60 * 60 * 1000);
                setRangeLabel(formatDate('MMM D', start) + ' – ' + formatDate('MMM D, YYYY', end));
                setTimeout(() => calendarRef.current?.navigate(now));
                refreshJobList();
              }}
            >
              Now
            </Button>
          </div>
        </div>
        <div className="mbsc-grid mbsc-no-padding mds-dispatch-content">
          <div className="mbsc-row mds-dispatch-full-height">
            <div className="mbsc-col-sm-3 mds-dispatch-full-height mds-dispatch-wrapper">
              <div className="mbsc-flex-col mbsc-flex-1-0 mbsc-padding">
                {jobListItems.length === 0 && (
                  <p className="mbsc-font mbsc-margin mbsc-medium mbsc-italic mbsc-txt-muted">No jobs for this period.</p>
                )}
                {jobListItems.map((item) =>
                  item.type === 'header' ? (
                    <div key={item.key} className="mds-dispatch-job-day-header">
                      {item.label}
                    </div>
                  ) : (
                    <JobCard key={item.key} job={item.job} />
                  ),
                )}
              </div>
            </div>
            <div className="mbsc-col-sm-9 mds-dispatch-column mds-dispatch-full-height">
              <Eventcalendar
                ref={calendarRef}
                clickToCreate={false}
                dragToCreate={false}
                dragToResize={false}
                dragToMove={true}
                externalDrop={true}
                eventOverlap={false}
                cssClass="mds-dispatch-calendar"
                refDate={refDate}
                zoomLevel={zoomLevel}
                view={calView}
                data={calendarData}
                resources={filteredResources}
                invalid={calendarInvalid}
                renderResource={renderResource}
                renderResourceEmpty={renderResourceEmpty}
                renderResourceHeader={renderResourceHeader}
                renderTimelineEventContent={renderTimelineEventContent}
                onEventCreated={onEventCreated}
                onEventCreateFailed={onEventCreateFailed}
                onEventUpdateFailed={onEventUpdateFailed}
                onEventDragStart={onEventDragStart}
                onEventDragEnd={onEventDragEnd}
                onEventClick={onEventClick}
                onPageLoaded={onPageLoaded}
              />
            </div>
          </div>
        </div>

        <Popup
          ref={filterPopupRef}
          buttons={filterPopupButtons}
          contentPadding={false}
          display="anchored"
          focusOnClose={false}
          focusOnOpen={false}
          showOverlay={false}
          width={400}
          anchor={filterAnchor}
        >
          <div className="mbsc-form-group">
            <div className="mbsc-form-group-title">Capacity</div>
            {allResources.map((res) => (
              <Checkbox
                key={res.id}
                label={res.name}
                checked={!!tempFilters[res.id]}
                onChange={(e) => setTempFilters((tf) => ({ ...tf, [res.id]: e.target.checked }))}
              />
            ))}
          </div>
          <div className="mbsc-form-group">
            <div className="mbsc-form-group-title">Operational Status</div>
            <Checkbox
              label="In maintenance/Maintenance planned"
              checked={!!tempFilters['maintenance']}
              onChange={(e) => setTempFilters((tf) => ({ ...tf, maintenance: e.target.checked }))}
            />
            <Checkbox
              label="Operational"
              checked={!!tempFilters['operational']}
              onChange={(e) => setTempFilters((tf) => ({ ...tf, operational: e.target.checked }))}
            />
          </div>
        </Popup>

        <Popup ref={rangePopupRef} responsive={rangePopupResponsive}>
          <div className="mds-dispatch-range-popup">
            <div className="mbsc-grid mbsc-no-padding">
              <div className="mbsc-row">
                <div className="mbsc-col-sm-4 mbsc-push-sm-8 mbsc-flex mbsc-flex-col">
                  <div className="mds-dispatch-range-inputs">
                    <Select
                      label="Date range"
                      labelStyle="stacked"
                      inputStyle="box"
                      data={rangeSelectData}
                      value={rangeSelectValue}
                      onChange={(args) => {
                        const val = args.value;
                        setRangeSelectValue(val);
                        if (val === 'custom') {
                          setRangeInputsDisabled(false);
                        } else {
                          setRangeInputsDisabled(true);
                          pendingRangeDaysRef.current = +val;
                          pendingRangeStartRef.current = todayRef.current;
                          const end = new Date(todayRef.current.getTime() + (+val - 1) * 24 * 60 * 60 * 1000);
                          setCalendarRangeValue([todayRef.current, end]);
                        }
                      }}
                    />
                    <Input
                      ref={rangeStartRef}
                      label="Start"
                      labelStyle="stacked"
                      inputStyle="box"
                      className="mds-dispatch-range-input"
                      disabled={rangeInputsDisabled}
                    />
                    <Input
                      ref={rangeEndRef}
                      label="End"
                      labelStyle="stacked"
                      inputStyle="box"
                      className="mds-dispatch-range-input"
                      disabled={rangeInputsDisabled}
                    />
                  </div>
                  <div className="mds-dispatch-range-desktop-btns mbsc-button-group-justified">
                    <Button onClick={applyPendingRange}>Apply</Button>
                    <Button onClick={() => rangePopupRef.current?.close()}>Cancel</Button>
                  </div>
                </div>
                <div className="mbsc-col-sm-8 mbsc-pull-sm-4">
                  <Datepicker
                    display="inline"
                    controls={['calendar']}
                    select="range"
                    showRangeLabels={false}
                    pages="auto"
                    showOnClick={false}
                    showOnFocus={false}
                    value={calendarRangeValue}
                    startInput={rangeStartInput}
                    endInput={rangeEndInput}
                    onChange={(args) => {
                      const dates = args.value;
                      if (dates) {
                        setCalendarRangeValue(dates);
                        setRangeInputsDisabled(false);
                        setRangeSelectValue('custom');
                        if (dates[0] && dates[1]) {
                          const start = new Date(dates[0]);
                          const end = new Date(dates[1]);
                          pendingRangeStartRef.current = start;
                          pendingRangeDaysRef.current = Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1;
                        }
                      }
                    }}
                  />
                </div>
              </div>
            </div>
          </div>
        </Popup>

        <Toast message={toastMessage} isOpen={isToastOpen} onClose={() => setIsToastOpen(false)} />
      </div>
    </Page>
  );
}

export default App;
```

### TSX

```tsx
import {
  Button,
  Checkbox,
  Datepicker,
  Draggable,
  Eventcalendar,
  formatDate,
  Input,
  MbscCalendarEvent,
  MbscCalendarEventData,
  MbscDatepickerChangeEvent,
  MbscEventcalendarView,
  MbscEventClickEvent,
  MbscEventCreatedEvent,
  MbscEventCreateFailedEvent,
  MbscEventDragEvent,
  MbscEventUpdateFailedEvent,
  MbscPageLoadedEvent,
  MbscPopupButton,
  MbscResource,
  MbscSelectChangeEvent,
  Page,
  Popup,
  Segmented,
  SegmentedGroup,
  Select,
  setOptions,
  Toast,
} from '@mobiscroll/react';
import { ChangeEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';

setOptions({
  theme: 'ios',
  themeVariant: 'light'
});

const statusColors: Record<string, string> = {
  scheduled: '#2196f3',
  'in progress': '#f97316',
  completed: '#78909c',
};

const maintenanceColors: Record<string, string> = {
  'in-maintenance': '#f44336',
  'maintenance-planned': '#ff9800',
  operational: '#4caf50',
};

const maintenanceLabels: Record<string, string> = {
  'in-maintenance': 'In Maintenance',
  'maintenance-planned': 'Maintenance Planned',
  operational: 'Operational',
};

interface TruckResource extends MbscResource {
  capacity?: number;
  status?: string;
  plate?: string;
  maintenanceFrom?: Date | string;
  maintenanceTo?: Date | string;
  isParent?: boolean;
}

interface CapacityGroup extends MbscResource {
  children: TruckResource[];
}

interface ExternalJob {
  id: number;
  from: string;
  to: string;
  size: number;
  pickup: [Date | string, Date | string];
  drop: [Date | string, Date | string];
  start?: Date | string;
  end?: Date | string;
  title?: string;
}

interface DispatchEvent extends MbscCalendarEvent {
  from?: string;
  to?: string;
  size?: number;
  pickup?: [Date | string, Date | string];
  drop?: [Date | string, Date | string];
  status?: string;
  actual?: boolean;
  actualRef?: DispatchEvent;
}

interface JobListItem {
  type: 'header' | 'job';
  label?: string;
  key: string;
  job?: ExternalJob;
}

type Filters = Record<string, boolean>;

interface CalendarMethods {
  navigate(date: Date): void;
  getEvents(): MbscCalendarEvent[];
  updateEvent(event: MbscCalendarEvent): void;
  addEvent(event: MbscCalendarEvent): void;
  navigateToEvent(event: { start?: Date | string; resource?: string | number }): void;
}

const allResources: CapacityGroup[] = [
  {
    id: 'cap1',
    name: '3.5 tons capacity',
    eventCreation: false,
    children: [
      { id: 1, name: 'Isuzu N-Series N35', capacity: 3.5, status: 'operational', plate: 'AB14 KTP' },
      {
        id: '1-actual',
        name: '',
        capacity: 3.5,
        status: 'operational',
        plate: 'AB14 KTP',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 2, name: 'Mercedes-Benz Sprinter 3500', capacity: 3.5, status: 'operational', plate: 'FR19 XDL' },
      {
        id: '2-actual',
        name: '',
        capacity: 3.5,
        status: 'operational',
        plate: 'FR19 XDL',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 3,
        name: 'Ford Transit 350',
        capacity: 3.5,
        status: 'operational',
        plate: 'QN62 RPV',
        maintenanceFrom: '2026-11-01T00:00',
        maintenanceTo: '2026-11-05T00:00',
      },
      {
        id: '3-actual',
        name: '',
        capacity: 3.5,
        status: 'operational',
        plate: 'QN62 RPV',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-01T00:00',
        maintenanceTo: '2026-11-05T00:00',
      },
    ],
  },
  {
    id: 'cap2',
    name: '7 tons capacity',
    eventCreation: false,
    children: [
      { id: 4, name: 'Ford F-650 Super Duty', capacity: 7, status: 'operational', plate: 'GR12 PEV' },
      {
        id: '4-actual',
        name: '',
        capacity: 7,
        status: 'operational',
        plate: 'GR12 PEV',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 5, name: 'Isuzu F-Series FTR', capacity: 7, status: 'operational', plate: 'MB27 FTK' },
      {
        id: '5-actual',
        name: '',
        capacity: 7,
        status: 'operational',
        plate: 'MB27 FTK',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 6,
        name: 'Hino 300 Series 716',
        capacity: 7,
        status: 'operational',
        plate: 'KT73 ZLD',
        maintenanceFrom: '2026-11-05T00:00',
        maintenanceTo: '2026-11-08T00:00',
      },
      {
        id: '6-actual',
        name: '',
        capacity: 7,
        status: 'operational',
        plate: 'KT73 ZLD',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-05T00:00',
        maintenanceTo: '2026-11-08T00:00',
      },
    ],
  },
  {
    id: 'cap3',
    name: '12 tons capacity',
    eventCreation: false,
    children: [
      { id: 7, name: 'Hino 500 Series FD', capacity: 12, status: 'operational', plate: 'EP17 GMF' },
      {
        id: '7-actual',
        name: '',
        capacity: 12,
        status: 'operational',
        plate: 'EP17 GMF',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 8, name: 'Isuzu F-Series FVR', capacity: 12, status: 'operational', plate: 'DS41 CXP' },
      {
        id: '8-actual',
        name: '',
        capacity: 12,
        status: 'operational',
        plate: 'DS41 CXP',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 9,
        name: 'Mercedes-Benz Atego 1218',
        capacity: 12,
        status: 'operational',
        plate: 'NH65 QWD',
        maintenanceFrom: '2026-11-02T00:00',
        maintenanceTo: '2026-11-04T00:00',
      },
      {
        id: '9-actual',
        name: '',
        capacity: 12,
        status: 'operational',
        plate: 'NH65 QWD',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-02T00:00',
        maintenanceTo: '2026-11-04T00:00',
      },
    ],
  },
  {
    id: 'cap4',
    name: '20 tons capacity',
    eventCreation: false,
    children: [
      { id: 10, name: 'Mercedes Actros 2545', capacity: 20, status: 'operational', plate: 'KT19 LNV' },
      {
        id: '10-actual',
        name: '',
        capacity: 20,
        status: 'operational',
        plate: 'KT19 LNV',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 11, name: 'DAF XF 530', capacity: 20, status: 'operational', plate: 'WP64 GBX' },
      {
        id: '11-actual',
        name: '',
        capacity: 20,
        status: 'operational',
        plate: 'WP64 GBX',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      {
        id: 12,
        name: 'Renault T High 520',
        capacity: 20,
        status: 'operational',
        plate: 'CD70 UJE',
        maintenanceFrom: '2026-11-07T00:00',
        maintenanceTo: '2026-11-10T00:00',
      },
      {
        id: '12-actual',
        name: '',
        capacity: 20,
        status: 'operational',
        plate: 'CD70 UJE',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
        maintenanceFrom: '2026-11-07T00:00',
        maintenanceTo: '2026-11-10T00:00',
      },
    ],
  },
  {
    id: 'cap5',
    name: '24 tons capacity',
    eventCreation: false,
    children: [
      { id: 13, name: 'Volvo FH16', capacity: 24, status: 'operational', plate: 'BD67 HTA' },
      {
        id: '13-actual',
        name: '',
        capacity: 24,
        status: 'operational',
        plate: 'BD67 HTA',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 14, name: 'Freightliner Cascadia', capacity: 24, status: 'operational', plate: 'VA18 RQW' },
      {
        id: '14-actual',
        name: '',
        capacity: 24,
        status: 'operational',
        plate: 'VA18 RQW',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
      { id: 15, name: 'Kenworth T680', capacity: 24, status: 'operational', plate: 'HY22 BPL' },
      {
        id: '15-actual',
        name: '',
        capacity: 24,
        status: 'operational',
        plate: 'HY22 BPL',
        eventCreation: false,
        cssClass: 'mds-dispatch-actual-resource',
      },
    ],
  },
];

function buildMaintenanceInvalids(): MbscCalendarEvent[] {
  const invalids: MbscCalendarEvent[] = [];
  allResources.forEach((group) => {
    group.children.forEach((resource) => {
      if (resource.maintenanceFrom && resource.maintenanceTo && !String(resource.id).includes('actual')) {
        invalids.push({
          start: resource.maintenanceFrom,
          end: resource.maintenanceTo,
          resource: resource.id,
          cssClass: 'mds-dispatch-maintenance-invalid',
        });
      }
    });
  });
  return invalids;
}

function initEvents(): DispatchEvent[] {
  return [
    {
      resource: 1,
      from: '100 Main St, Dallas, TX',
      to: '50 Congress Ave, Tulsa, OK',
      size: 3,
      pickup: ['2026-11-02T08:00', '2026-11-02T10:00'],
      drop: ['2026-11-02T12:00', '2026-11-02T14:00'],
      status: 'completed',
    },
    {
      resource: 2,
      from: '200 Elm St, Fargo, ND',
      to: '75 N 3rd St, Bismarck, ND',
      size: 2.5,
      pickup: ['2026-11-03T09:00', '2026-11-03T11:00'],
      drop: ['2026-11-03T13:00', '2026-11-03T15:00'],
      status: 'in progress',
    },
    {
      resource: 3,
      from: '150 Broadway, Manchester, NH',
      to: '50 Kennedy Memorial Dr, Waterville, ME',
      size: 3.5,
      pickup: ['2026-11-06T07:00', '2026-11-06T09:00'],
      drop: ['2026-11-06T12:00', '2026-11-06T14:00'],
      status: 'scheduled',
    },
    {
      resource: 1,
      from: '50 N Main St, Phoenix, AZ',
      to: '200 Broadway Ave, Albuquerque, NM',
      size: 3,
      pickup: ['2026-11-06T10:00', '2026-11-06T12:00'],
      drop: ['2026-11-06T15:00', '2026-11-06T17:00'],
      status: 'scheduled',
    },
    {
      resource: 4,
      from: '85 Arch St, Boston, MA',
      to: '40 Middle St, Portland, ME',
      size: 6.5,
      pickup: ['2026-11-02T08:00', '2026-11-02T10:00'],
      drop: ['2026-11-02T12:00', '2026-11-02T14:00'],
      status: 'completed',
    },
    {
      resource: 5,
      from: '300 E Main St, Richmond, VA',
      to: '100 S Tryon St, Charlotte, NC',
      size: 7,
      pickup: ['2026-11-03T05:00', '2026-11-03T07:00'],
      drop: ['2026-11-03T10:00', '2026-11-03T12:00'],
      status: 'completed',
    },
    {
      resource: 6,
      from: '900 W Belmont Ave, Chicago, IL',
      to: '300 Market St, St Louis, MO',
      size: 6,
      pickup: ['2026-11-09T09:00', '2026-11-09T11:00'],
      drop: ['2026-11-09T14:00', '2026-11-09T16:00'],
      status: 'scheduled',
    },
    {
      resource: 4,
      from: '250 King St, Greensboro, NC',
      to: '200 Hay St, Fayetteville, NC',
      size: 6.5,
      pickup: ['2026-11-03T13:00', '2026-11-03T15:00'],
      drop: ['2026-11-03T18:00', '2026-11-03T20:00'],
      status: 'scheduled',
    },
    {
      resource: 7,
      from: '233 S Wacker Dr, Chicago, IL',
      to: '10 Public Square, Cleveland, OH',
      size: 11,
      pickup: ['2026-11-03T08:00', '2026-11-03T11:00'],
      drop: ['2026-11-03T16:00', '2026-11-03T19:00'],
      status: 'scheduled',
    },
    {
      resource: 8,
      from: '180 S High St, Columbus, OH',
      to: '75 E Main St, Lexington, KY',
      size: 12,
      pickup: ['2026-11-05T07:00', '2026-11-05T10:00'],
      drop: ['2026-11-05T15:00', '2026-11-05T18:00'],
      status: 'scheduled',
    },
    {
      resource: 9,
      from: '100 E Capitol St, Jackson, MS',
      to: '250 Riverfront Pkwy, Chattanooga, TN',
      size: 10,
      pickup: ['2026-11-08T07:00', '2026-11-08T10:00'],
      drop: ['2026-11-08T16:00', '2026-11-08T19:00'],
      status: 'scheduled',
    },
    {
      resource: 7,
      from: '50 Arch St, Boston, MA',
      to: '200 Middle St, Portland, ME',
      size: 9,
      pickup: ['2026-11-09T08:00', '2026-11-09T10:00'],
      drop: ['2026-11-09T12:00', '2026-11-09T14:00'],
      status: 'scheduled',
    },
    {
      resource: 10,
      from: '50 N 1st Ave, Phoenix, AZ',
      to: '200 Broadway Ave, Albuquerque, NM',
      size: 20,
      pickup: ['2026-11-03T10:00', '2026-11-03T13:00'],
      drop: ['2026-11-03T18:00', '2026-11-03T21:00'],
      status: 'in progress',
    },
    {
      resource: 11,
      from: '120 Broadway, New York, NY',
      to: '55 Canal St, New Orleans, LA',
      size: 18,
      pickup: ['2026-11-06T06:00', '2026-11-06T09:00'],
      drop: ['2026-11-06T10:00', '2026-11-06T14:00'],
      status: 'scheduled',
    },
    {
      resource: 12,
      from: '600 Poydras St, New Orleans, LA',
      to: '200 Commerce St, Montgomery, AL',
      size: 20,
      pickup: ['2026-11-10T06:00', '2026-11-10T09:00'],
      drop: ['2026-11-10T14:00', '2026-11-10T17:00'],
      status: 'scheduled',
    },
    {
      resource: 10,
      from: '120 Main St, Boston, MA',
      to: '50 Canal St, New Orleans, LA',
      size: 19,
      pickup: ['2026-11-04T07:00', '2026-11-04T10:00'],
      drop: ['2026-11-04T14:00', '2026-11-04T17:00'],
      status: 'scheduled',
    },
    {
      resource: 13,
      from: '100 Main St, Dallas, TX',
      to: '15 Broadway St, Denver, CO',
      size: 22,
      pickup: ['2026-11-03T07:00', '2026-11-03T10:00'],
      drop: ['2026-11-04T04:00', '2026-11-04T08:00'],
      status: 'scheduled',
    },
    {
      resource: 14,
      from: '300 Main St, Houston, TX',
      to: '120 W Capitol Ave, Little Rock, AR',
      size: 24,
      pickup: ['2026-11-05T10:00', '2026-11-05T13:00'],
      drop: ['2026-11-05T18:00', '2026-11-05T22:00'],
      status: 'scheduled',
    },
    {
      resource: 15,
      from: '900 S Flower St, Los Angeles, CA',
      to: '300 2nd Ave, Seattle, WA',
      size: 23,
      pickup: ['2026-11-08T05:00', '2026-11-08T08:00'],
      drop: ['2026-11-08T12:00', '2026-11-08T16:00'],
      status: 'scheduled',
    },
    {
      resource: 13,
      from: '350 S Spring St, Los Angeles, CA',
      to: '500 E Rim Rd, El Paso, TX',
      size: 24,
      pickup: ['2026-11-11T06:00', '2026-11-11T09:00'],
      drop: ['2026-11-11T18:00', '2026-11-11T21:00'],
      status: 'scheduled',
    },
    {
      resource: 14,
      from: '200 Market St, Philadelphia, PA',
      to: '400 Washington Ave, Minneapolis, MN',
      size: 23,
      pickup: ['2026-11-06T13:00', '2026-11-06T16:00'],
      drop: ['2026-11-06T20:00', '2026-11-06T23:00'],
      status: 'scheduled',
    },
    {
      resource: 15,
      from: '500 S Wacker Dr, Chicago, IL',
      to: '100 State St, Portland, ME',
      size: 24,
      pickup: ['2026-11-09T08:00', '2026-11-09T11:00'],
      drop: ['2026-11-09T16:00', '2026-11-09T19:00'],
      status: 'scheduled',
    },
    {
      resource: 1,
      from: '300 W Madison St, Chicago, IL',
      to: '500 Euclid Ave, Cleveland, OH',
      size: 3,
      pickup: ['2026-11-04T08:00', '2026-11-04T10:00'],
      drop: ['2026-11-04T13:00', '2026-11-04T16:00'],
      status: 'scheduled',
    },
    {
      resource: 4,
      from: '800 N 3rd St, Philadelphia, PA',
      to: '200 W Baltimore St, Baltimore, MD',
      size: 6.5,
      pickup: ['2026-11-05T07:00', '2026-11-05T09:00'],
      drop: ['2026-11-05T12:00', '2026-11-05T15:00'],
      status: 'scheduled',
    },
    {
      resource: 5,
      from: '900 Biscayne Blvd, Miami, FL',
      to: '400 N Orange Ave, Orlando, FL',
      size: 7,
      pickup: ['2026-11-04T09:00', '2026-11-04T11:00'],
      drop: ['2026-11-04T14:00', '2026-11-04T18:00'],
      status: 'scheduled',
    },
    {
      resource: 7,
      from: '100 S 5th St, Minneapolis, MN',
      to: '300 W Kellogg Blvd, St Paul, MN',
      size: 9,
      pickup: ['2026-11-06T08:00', '2026-11-06T10:00'],
      drop: ['2026-11-06T14:00', '2026-11-06T17:00'],
      status: 'scheduled',
    },
    {
      resource: 8,
      from: '500 W 2nd St, Austin, TX',
      to: '2200 Post Oak Blvd, Houston, TX',
      size: 12,
      pickup: ['2026-11-07T07:00', '2026-11-07T10:00'],
      drop: ['2026-11-07T15:00', '2026-11-07T18:00'],
      status: 'scheduled',
    },
    {
      resource: 11,
      from: '600 Grant St, Pittsburgh, PA',
      to: '200 W Washington St, Indianapolis, IN',
      size: 18,
      pickup: ['2026-11-04T06:00', '2026-11-04T09:00'],
      drop: ['2026-11-04T16:00', '2026-11-04T20:00'],
      status: 'scheduled',
    },
    {
      resource: 14,
      from: '400 N Michigan Ave, Chicago, IL',
      to: '250 E Wisconsin Ave, Milwaukee, WI',
      size: 23,
      pickup: ['2026-11-08T08:00', '2026-11-08T11:00'],
      drop: ['2026-11-08T15:00', '2026-11-08T18:00'],
      status: 'scheduled',
    },
    {
      resource: 13,
      from: '700 Figueroa St, Los Angeles, CA',
      to: '50 W San Fernando St, San Jose, CA',
      size: 22,
      pickup: ['2026-11-07T06:00', '2026-11-07T09:00'],
      drop: ['2026-11-07T17:00', '2026-11-07T21:00'],
      status: 'scheduled',
    },
  ].map((e) => ({ ...e, start: e.pickup[0], end: e.drop[0], title: e.from + ' → ' + e.to })) as DispatchEvent[];
}

const allExternalEvents: ExternalJob[] = [
  {
    id: 1,
    from: '2000 Q St, Sacramento, CA',
    to: '500 Poydras St, New Orleans, LA',
    size: 9,
    pickup: ['2026-11-03T09:00', '2026-11-03T11:00'],
    drop: ['2026-11-03T12:00', '2026-11-03T17:00'],
  },
  {
    id: 2,
    from: '100 State St, Madison, WI',
    to: '300 S Wacker Dr, Chicago, IL',
    size: 22,
    pickup: ['2026-11-03T13:00', '2026-11-03T15:00'],
    drop: ['2026-11-03T16:00', '2026-11-03T21:00'],
  },
  {
    id: 3,
    from: '1500 Market St, Philadelphia, PA',
    to: '600 Liberty St, Pittsburgh, PA',
    size: 13,
    pickup: ['2026-11-03T13:00', '2026-11-03T17:00'],
    drop: ['2026-11-03T18:00', '2026-11-03T22:00'],
  },
  {
    id: 25,
    from: '350 Fifth Ave, New York, NY',
    to: '100 Broad St, Newark, NJ',
    size: 3.5,
    pickup: ['2026-11-03T14:00', '2026-11-03T16:00'],
    drop: ['2026-11-03T18:00', '2026-11-03T21:00'],
  },
  {
    id: 26,
    from: '800 Market St, San Francisco, CA',
    to: '400 Capitol Mall, Sacramento, CA',
    size: 6,
    pickup: ['2026-11-03T15:00', '2026-11-03T17:00'],
    drop: ['2026-11-03T19:00', '2026-11-03T22:00'],
  },
  {
    id: 4,
    from: '1100 Congress Ave, Austin, TX',
    to: '500 E Main St, Dallas, TX',
    size: 20,
    pickup: ['2026-11-04T07:00', '2026-11-04T09:00'],
    drop: ['2026-11-04T10:00', '2026-11-04T15:00'],
  },
  {
    id: 5,
    from: '200 Peachtree St NW, Atlanta, GA',
    to: '50 Music Sq E, Nashville, TN',
    size: 8,
    pickup: ['2026-11-04T08:00', '2026-11-04T10:00'],
    drop: ['2026-11-04T12:00', '2026-11-04T16:00'],
  },
  {
    id: 6,
    from: '500 Boylston St, Boston, MA',
    to: '200 State St, Portland, ME',
    size: 3.5,
    pickup: ['2026-11-04T08:30', '2026-11-04T10:30'],
    drop: ['2026-11-04T12:30', '2026-11-04T18:30'],
  },
  {
    id: 27,
    from: '200 S Lamar Blvd, Austin, TX',
    to: '500 Commerce St, Dallas, TX',
    size: 19,
    pickup: ['2026-11-04T13:00', '2026-11-04T15:00'],
    drop: ['2026-11-04T18:00', '2026-11-04T22:00'],
  },
  {
    id: 28,
    from: '300 W Colfax Ave, Denver, CO',
    to: '100 N Main St, Pueblo, CO',
    size: 10,
    pickup: ['2026-11-05T10:00', '2026-11-05T12:00'],
    drop: ['2026-11-05T14:00', '2026-11-05T18:00'],
  },
  {
    id: 7,
    from: '400 S 4th St, Louisville, KY',
    to: '1400 Main St, Cincinnati, OH',
    size: 7,
    pickup: ['2026-11-05T06:00', '2026-11-05T08:00'],
    drop: ['2026-11-05T09:00', '2026-11-05T12:00'],
  },
  {
    id: 8,
    from: '410 S Houston Ave, Tulsa, OK',
    to: '250 W Capitol Ave, Little Rock, AR',
    size: 11.5,
    pickup: ['2026-11-05T06:30', '2026-11-05T08:30'],
    drop: ['2026-11-05T10:30', '2026-11-05T15:30'],
  },
  {
    id: 29,
    from: '450 Sutter St, San Francisco, CA',
    to: '200 Pine Ave, Long Beach, CA',
    size: 22,
    pickup: ['2026-11-06T07:00', '2026-11-06T09:00'],
    drop: ['2026-11-06T13:00', '2026-11-06T17:00'],
  },
  {
    id: 9,
    from: '999 3rd Ave, Seattle, WA',
    to: '500 W 8th St, Vancouver, WA',
    size: 3,
    pickup: ['2026-11-06T09:00', '2026-11-06T11:00'],
    drop: ['2026-11-06T12:00', '2026-11-06T17:00'],
  },
  {
    id: 10,
    from: '50 S Main St, Salt Lake City, UT',
    to: '300 N Broadway, Denver, CO',
    size: 5,
    pickup: ['2026-11-06T10:00', '2026-11-06T12:00'],
    drop: ['2026-11-06T13:00', '2026-11-06T18:00'],
  },
  {
    id: 11,
    from: '300 S Grand Ave, Los Angeles, CA',
    to: '400 E Van Buren St, Phoenix, AZ',
    size: 5,
    pickup: ['2026-11-10T08:00', '2026-11-10T10:00'],
    drop: ['2026-11-10T13:00', '2026-11-10T18:00'],
  },
  {
    id: 12,
    from: '200 W Washington St, Indianapolis, IN',
    to: '100 N Main St, Louisville, KY',
    size: 18,
    pickup: ['2026-11-11T07:00', '2026-11-11T09:00'],
    drop: ['2026-11-11T12:00', '2026-11-11T17:00'],
  },
  {
    id: 13,
    from: '500 N Michigan Ave, Chicago, IL',
    to: '250 Marquette Ave, Minneapolis, MN',
    size: 3,
    pickup: ['2026-11-12T09:00', '2026-11-12T11:00'],
    drop: ['2026-11-12T14:00', '2026-11-12T19:00'],
  },
  {
    id: 14,
    from: '600 Congress Ave, Austin, TX',
    to: '700 Texas Ave, Houston, TX',
    size: 12,
    pickup: ['2026-11-13T06:00', '2026-11-13T08:00'],
    drop: ['2026-11-13T11:00', '2026-11-13T16:00'],
  },
  {
    id: 15,
    from: '100 Broad St, Newark, NJ',
    to: '200 S Orange Ave, Orlando, FL',
    size: 7,
    pickup: ['2026-11-15T10:00', '2026-11-15T12:00'],
    drop: ['2026-11-15T15:00', '2026-11-15T20:00'],
  },
  {
    id: 16,
    from: '400 Pike St, Seattle, WA',
    to: '800 SW Broadway, Portland, OR',
    size: 9,
    pickup: ['2026-11-17T07:00', '2026-11-17T09:00'],
    drop: ['2026-11-17T12:00', '2026-11-17T16:00'],
  },
  {
    id: 17,
    from: '300 Main St, Buffalo, NY',
    to: '500 Euclid Ave, Cleveland, OH',
    size: 22,
    pickup: ['2026-11-18T08:00', '2026-11-18T11:00'],
    drop: ['2026-11-18T14:00', '2026-11-18T19:00'],
  },
  {
    id: 18,
    from: '150 S Miami Ave, Miami, FL',
    to: '300 Peachtree St, Atlanta, GA',
    size: 6,
    pickup: ['2026-11-19T09:00', '2026-11-19T11:00'],
    drop: ['2026-11-19T13:00', '2026-11-19T18:00'],
  },
  {
    id: 19,
    from: '700 W 3rd St, Kansas City, MO',
    to: '400 N Broadway, St Louis, MO',
    size: 15,
    pickup: ['2026-11-21T06:00', '2026-11-21T08:00'],
    drop: ['2026-11-21T10:00', '2026-11-21T15:00'],
  },
  {
    id: 20,
    from: '250 E Houston St, San Antonio, TX',
    to: '1200 Commerce St, Dallas, TX',
    size: 4,
    pickup: ['2026-11-22T11:00', '2026-11-22T13:00'],
    drop: ['2026-11-22T15:00', '2026-11-22T20:00'],
  },
  {
    id: 21,
    from: '800 N Michigan Ave, Chicago, IL',
    to: '500 Woodward Ave, Detroit, MI',
    size: 11,
    pickup: ['2026-11-24T07:00', '2026-11-24T09:00'],
    drop: ['2026-11-24T12:00', '2026-11-24T17:00'],
  },
  {
    id: 22,
    from: '900 Market St, San Francisco, CA',
    to: '600 Wilshire Blvd, Los Angeles, CA',
    size: 20,
    pickup: ['2026-11-25T08:00', '2026-11-25T10:00'],
    drop: ['2026-11-25T14:00', '2026-11-25T19:00'],
  },
  {
    id: 23,
    from: '350 5th Ave, New York, NY',
    to: '100 Federal St, Boston, MA',
    size: 3.5,
    pickup: ['2026-11-26T09:00', '2026-11-26T11:00'],
    drop: ['2026-11-26T13:00', '2026-11-26T17:00'],
  },
  {
    id: 24,
    from: '200 S Tryon St, Charlotte, NC',
    to: '100 N Main St, Greenville, SC',
    size: 8,
    pickup: ['2026-11-28T06:00', '2026-11-28T08:00'],
    drop: ['2026-11-28T10:00', '2026-11-28T14:00'],
  },
];

function initExternalEvents(): ExternalJob[] {
  const events = allExternalEvents.map((e) => ({ ...e }));
  events.forEach((ev) => {
    ev.start = ev.start || ev.pickup[0];
    ev.end = ev.end || ev.drop[0];
    ev.title = ev.from + ' → ' + ev.to;
  });
  return events;
}

function getResourceMaintenanceStatus(resource: TruckResource, now: Date, currentViewStart: Date, currentViewEnd: Date): string {
  if (!resource.maintenanceFrom || !resource.maintenanceTo) return 'operational';
  const from = new Date(resource.maintenanceFrom as string);
  const to = new Date(resource.maintenanceTo as string);
  if (now >= from && now <= to) return 'in-maintenance';
  if (from < currentViewEnd && to > currentViewStart) return 'maintenance-planned';
  return 'operational';
}

function buildViewConfig(days: number): MbscEventcalendarView {
  return {
    timeline: {
      type: 'day',
      size: days,
      timeCellStep: 60,
      timeLabelStep: 60,
      eventHeight: 'variable',
      zoomLevels: {
        1: { type: 'day', size: days, columnWidth: 'xsmall', timeCellStep: 240, timeLabelStep: 240 },
        2: { type: 'day', size: days, columnWidth: 'small', timeCellStep: 120, timeLabelStep: 120 },
        3: { type: 'day', size: days, columnWidth: 'medium', timeCellStep: 60, timeLabelStep: 60 },
        4: { type: 'day', size: days, columnWidth: 'xlarge', timeCellStep: 30, timeLabelStep: 60 },
        5: { type: 'day', size: days, columnWidth: 'xlarge', timeCellStep: 15, timeLabelStep: 60 },
      },
    },
  };
}

function JobCard({ job }: { job: ExternalJob }) {
  const [elem, setElem] = useState<HTMLDivElement | null>(null);
  return (
    <div ref={setElem} className="mds-dispatch-jobs mbsc-flex">
      <div className="mds-dispatch-job-route">
        <div className="mds-dispatch-job-stop mbsc-flex">
          <span className="mds-dispatch-job-dot mds-dispatch-job-dot-origin"></span>
          <span className="mds-dispatch-job-addr">{job.from}</span>
        </div>
        <div className="mds-dispatch-job-connector"></div>
        <div className="mds-dispatch-job-stop mbsc-flex">
          <span className="mds-dispatch-job-dot mds-dispatch-job-dot-dest"></span>
          <span className="mds-dispatch-job-addr">{job.to}</span>
        </div>
      </div>
      <div className="mds-dispatch-job-meta mbsc-flex-col mbsc-flex-none">
        <span className="mds-dispatch-job-time">
          {formatDate('H:mm', new Date(job.pickup[0] as string))} – {formatDate('H:mm', new Date(job.drop[1] as string))}
        </span>
        <span className="mds-dispatch-job-size">{job.size} t</span>
      </div>
      <Draggable dragData={job} element={elem} />
    </div>
  );
}

interface ResourceHeaderProps {
  onSearch: (q: string) => void;
  onFilterOpen: (e: MouseEvent<HTMLButtonElement>) => void;
}

function ResourceHeader({ onSearch, onFilterOpen }: ResourceHeaderProps) {
  const [query, setQuery] = useState<string>('');

  return (
    <div className="mbsc-flex mbsc-align-items-center mbsc-font mds-dispatch-search">
      <Input
        className="mbsc-flex-1-1"
        type="text"
        inputStyle="outline"
        startIcon="material-search"
        autoComplete="off"
        placeholder="Search..."
        value={query}
        onChange={(e: ChangeEvent<HTMLInputElement>) => {
          const q = e.target.value.toLowerCase();
          setQuery(q);
          onSearch(q);
        }}
      />
      <Button startIcon="material-filter-list" variant="outline" className="mbsc-flex-none" onClick={onFilterOpen}>
        Filter
      </Button>
    </div>
  );
}

function App() {
  const _initNow = new Date();
  const _initToday = new Date(_initNow.getFullYear(), _initNow.getMonth(), _initNow.getDate());
  const nowRef = useRef<Date>(_initNow);
  const todayRef = useRef<Date>(_initToday);
  const searchQueryRef = useRef<string>('');
  const calendarRef = useRef<(Eventcalendar & CalendarMethods) | null>(null);
  const myEventsRef = useRef<DispatchEvent[]>(null!);
  if (!myEventsRef.current) myEventsRef.current = initEvents();
  const externalEventsRef = useRef<ExternalJob[]>(null!);
  if (!externalEventsRef.current) externalEventsRef.current = initExternalEvents();
  const scheduledJobIdsRef = useRef<number[]>([]);
  const myInvalidsRef = useRef<MbscCalendarEvent[]>([]);
  const currentViewStartRef = useRef<Date>(_initToday);
  const currentViewEndRef = useRef<Date>(new Date(_initToday.getTime() + 7 * 24 * 60 * 60 * 1000));
  const currentRangeStartRef = useRef<Date>(_initToday);
  const currentRangeDaysRef = useRef<number>(7);
  const pendingRangeStartRef = useRef<Date>(_initToday);
  const pendingRangeDaysRef = useRef<number>(7);
  const filterPopupRef = useRef<Popup | null>(null);
  const rangePopupRef = useRef<Popup | null>(null);
  const [filterAnchor, setFilterAnchor] = useState<HTMLElement | null>(null);

  const [myEvents, setMyEvents] = useState<DispatchEvent[]>(initEvents);
  const [filteredResources, setFilteredResources] = useState<CapacityGroup[]>([]);
  const [maintenanceInvalids] = useState<MbscCalendarEvent[]>(buildMaintenanceInvalids);
  const [calendarInvalid, setCalendarInvalid] = useState<MbscCalendarEvent[]>(buildMaintenanceInvalids);
  const [zoomLevel, setZoomLevel] = useState<number>(3);
  const [calView, setCalView] = useState<MbscEventcalendarView>(() => buildViewConfig(7));
  const [refDate, setRefDate] = useState<Date>(_initToday);
  const [jobListItems, setJobListItems] = useState<JobListItem[]>([]);
  const [filters, setFilters] = useState<Filters>(() => {
    const f: Filters = { operational: true, maintenance: true };
    allResources.forEach((cat) => {
      f[cat.id as string] = true;
    });
    return f;
  });
  const [tempFilters, setTempFilters] = useState<Filters>({});
  const [statusFilters, setStatusFilters] = useState<string[]>(['scheduled', 'in progress', 'completed']);
  const [resetKey, setResetKey] = useState<number>(0);
  const [rangeLabel, setRangeLabel] = useState<string>('');
  const [rangeSelectValue, setRangeSelectValue] = useState<string>('7');
  const [rangeInputsDisabled, setRangeInputsDisabled] = useState<boolean>(true);
  const [calendarRangeValue, setCalendarRangeValue] = useState<[Date, Date] | null>(null);
  const [rangeStartInput, rangeStartRef] = useState<Input | null>(null);
  const [rangeEndInput, rangeEndRef] = useState<Input | null>(null);
  const [isToastOpen, setIsToastOpen] = useState<boolean>(false);
  const [toastMessage, setToastMessage] = useState<string>('');

  const showToast = useCallback((msg: string) => {
    setToastMessage(msg);
    setIsToastOpen(true);
  }, []);

  const filtersRef = useRef<Filters>(filters);
  filtersRef.current = filters;

  const computeFilteredResources = useCallback(
    (currentFilters?: Filters, currentSearchQuery?: string): CapacityGroup[] => {
      const f = currentFilters !== undefined ? currentFilters : filtersRef.current;
      const sq = currentSearchQuery !== undefined ? currentSearchQuery : searchQueryRef.current;
      return allResources
        .map((category) => {
          const keptIds: Record<string, boolean> = {};
          category.children.forEach((resource) => {
            if (String(resource.id).includes('actual')) return;
            const filterKey =
              getResourceMaintenanceStatus(resource, nowRef.current, currentViewStartRef.current, currentViewEndRef.current) ===
              'operational'
                ? 'operational'
                : 'maintenance';
            if (
              f[filterKey] &&
              (!sq || resource.name!.toLowerCase().includes(sq.toLowerCase()) || resource.plate!.toLowerCase().includes(sq.toLowerCase()))
            ) {
              keptIds[String(resource.id)] = true;
            }
          });
          return {
            id: category.id,
            name: category.name,
            eventCreation: category.eventCreation,
            children: category.children.filter((resource) => {
              if (String(resource.id).includes('actual')) {
                const parentId = String(resource.id).replace('-actual', '');
                if (!keptIds[parentId]) return false;
                const parent = category.children.find((r) => String(r.id) === parentId);
                return (
                  !parent ||
                  getResourceMaintenanceStatus(parent, nowRef.current, currentViewStartRef.current, currentViewEndRef.current) ===
                    'operational'
                );
              }
              return keptIds[String(resource.id)];
            }),
          };
        })
        .filter((res) => res.children.length > 0 && f[res.id as string]);
    },
    [nowRef, currentViewStartRef, currentViewEndRef],
  );

  const getEffectiveStatus = useCallback((ev: DispatchEvent): string => {
    if (ev.status === 'actual') return ev.title === 'Completed' ? 'completed' : 'in progress';
    if (ev.end && new Date(ev.end as string) <= nowRef.current) return 'completed';
    if (ev.start && new Date(ev.start as string) < nowRef.current) return 'in progress';
    return ev.status || 'scheduled';
  }, []);

  const getStatusFilteredEvents = useCallback(
    (events: DispatchEvent[], currentStatusFilters?: string[]): DispatchEvent[] => {
      const sf = currentStatusFilters || statusFilters;
      return events.filter((ev) => sf.includes(getEffectiveStatus(ev)));
    },
    [statusFilters, getEffectiveStatus],
  );

  const getMaxAvailableCapacity = useCallback((currentFilters?: Filters): number => {
    const f = currentFilters !== undefined ? currentFilters : filtersRef.current;
    let max = 0;
    allResources.forEach((group) => {
      if (f[group.id as string]) {
        group.children.forEach((child) => {
          if (!String(child.id).includes('actual') && (child.capacity || 0) > max) max = child.capacity!;
        });
      }
    });
    return max;
  }, []);

  const refreshJobList = useCallback(
    (currentFilters?: Filters, currentScheduledIds?: number[], rangeStart?: Date, rangeDays?: number) => {
      const now = nowRef.current;
      const rStart = rangeStart || currentRangeStartRef.current;
      const rDays = rangeDays || currentRangeDaysRef.current;
      const rangeEnd = new Date(rStart.getTime() + rDays * 24 * 60 * 60 * 1000);
      const minPickup = new Date(now.getTime() + 2 * 60 * 60 * 1000);
      const maxCapacity = getMaxAvailableCapacity(currentFilters);
      const sIds = currentScheduledIds || scheduledJobIdsRef.current;
      const visible = externalEventsRef.current.filter((job) => {
        if (sIds.indexOf(job.id) !== -1) return false;
        if (job.size > maxCapacity) return false;
        const pickupStart = new Date(job.pickup[0] as string);
        if (pickupStart < minPickup) return false;
        return pickupStart >= rStart && pickupStart < rangeEnd;
      });
      const items: JobListItem[] = [];
      let lastDayKey: string | null = null;
      for (const job of visible) {
        const pickupDate = new Date(job.pickup[0] as string);
        const dayKey = formatDate('YYYY-MM-DD', pickupDate);
        if (dayKey !== lastDayKey) {
          items.push({ type: 'header', label: formatDate('DDD, DD MMM', pickupDate), key: 'hdr-' + dayKey });
          lastDayKey = dayKey;
        }
        items.push({ type: 'job', job, key: 'job-' + job.id });
      }
      setJobListItems(items);
    },
    [getMaxAvailableCapacity, nowRef, currentRangeStartRef, currentRangeDaysRef, scheduledJobIdsRef, externalEventsRef],
  );

  const addActualEvents = useCallback(
    (events: DispatchEvent[]) => {
      const now = nowRef.current;
      const possibleOffsets = [-120, -90, -60, -30, -15, 0, 15, 30, 60, 90, 120];
      for (let i = 0; i < events.length; ++i) {
        const event = events[i];
        event.start = event.start ? new Date(event.start as string) : event.start;
        event.end = event.end ? new Date(event.end as string) : event.end;
        if (event.status === 'actual') continue;
        const evEnd = event.end as Date;
        const evStart = event.start as Date;
        if (evEnd <= now) event.color = statusColors['completed'];
        else if (evStart < now) event.color = statusColors['in progress'];
        else event.color = statusColors[event.status!] || statusColors['scheduled'];
        event.editable = now < evStart;
        if (evStart < now && evEnd > now) {
          if (!event.actual) {
            const dOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            const aStart = new Date(evStart.getTime() + dOff);
            const actualStart = aStart >= evStart && aStart < now ? aStart : evStart;
            const newEvent: DispatchEvent = {
              resource: event.resource + '-actual',
              from: event.from,
              to: event.to,
              pickup: event.pickup,
              drop: event.drop,
              size: event.size,
              start: actualStart,
              end: now,
              title: 'In progress',
              status: 'actual',
              color: event.color,
              cssClass: 'mds-dispatch-actual-event mds-dispatch-pulse',
              editable: false,
            };
            event.actual = true;
            event.actualRef = newEvent;
            events.push(newEvent);
          } else if (event.actualRef) {
            event.actualRef.end = now;
          }
        } else if (evEnd <= now) {
          if (!event.actual) {
            const dOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            const aOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            let cAStart = new Date(evStart.getTime() + dOff);
            if (!(cAStart >= evStart && cAStart < evEnd)) cAStart = evStart;
            const drop0 =
              event.drop![0] instanceof Date ? (event.drop![0] as Date).getTime() : new Date(event.drop![0] as string).getTime();
            let cAEnd = new Date(drop0 + aOff);
            const minEnd = new Date(cAStart.getTime() + 30 * 60000);
            if (cAEnd < minEnd) cAEnd = minEnd;
            const cNewEvent: DispatchEvent = {
              resource: event.resource + '-actual',
              from: event.from,
              to: event.to,
              pickup: event.pickup,
              drop: event.drop,
              size: event.size,
              start: cAStart,
              end: cAEnd,
              title: 'Completed',
              status: 'actual',
              color: event.color,
              cssClass: 'mds-dispatch-actual-event',
              editable: false,
            };
            event.actual = true;
            event.actualRef = cNewEvent;
            events.push(cNewEvent);
          } else if (event.actualRef && event.actualRef.title !== 'Completed') {
            const aOff = possibleOffsets[Math.floor(Math.random() * possibleOffsets.length)] * 60000;
            const drop0 =
              event.drop![0] instanceof Date ? (event.drop![0] as Date).getTime() : new Date(event.drop![0] as string).getTime();
            let cAEnd = new Date(drop0 + aOff);
            const minEnd = new Date((event.actualRef.start as Date).getTime() + 30 * 60000);
            if (cAEnd < minEnd) cAEnd = minEnd;
            event.actualRef.end = cAEnd;
            event.actualRef.color = event.color;
            event.actualRef.cssClass = 'mds-dispatch-actual-event';
            event.actualRef.title = 'Completed';
          }
        }
      }
    },
    [nowRef],
  );

  const findResourceById = useCallback((id: string | number): TruckResource | null => {
    const baseId = String(id).replace('-actual', '');
    for (const group of allResources) {
      for (const child of group.children) {
        if (String(child.id) === baseId) return child;
      }
    }
    return null;
  }, []);

  const findFirstSlot = useCallback(
    (draggedEvent: DispatchEvent): { start: Date; end: Date } | null => {
      const now = new Date();
      const minStart = new Date(now.getTime() + 2 * 60 * 60 * 1000);
      const windowStart = new Date(draggedEvent.pickup![0] as string);
      const windowEnd = new Date(draggedEvent.drop![1] as string);
      let eventStart = new Date(draggedEvent.start as string);
      let eventEnd = new Date(draggedEvent.end as string);
      const effectiveWindowStart = windowStart < minStart ? minStart : windowStart;
      const eventDuration = eventEnd.getTime() - eventStart.getTime();
      const windowSize = windowEnd.getTime() - effectiveWindowStart.getTime();
      if (eventDuration > windowSize) return null;
      if (eventStart < effectiveWindowStart) {
        eventStart = new Date(effectiveWindowStart);
        eventEnd = new Date(effectiveWindowStart.getTime() + eventDuration);
      }
      if (eventEnd > windowEnd) {
        eventEnd = new Date(windowEnd);
        eventStart = new Date(windowEnd.getTime() - eventDuration);
      }
      if (draggedEvent.resource) {
        const resource = findResourceById(draggedEvent.resource as string | number);
        if (resource?.maintenanceFrom && resource?.maintenanceTo) {
          const mFrom = new Date(resource.maintenanceFrom as string);
          const mTo = new Date(resource.maintenanceTo as string);
          if (eventStart < mTo && eventEnd > mFrom) {
            eventStart = new Date(mTo);
            eventEnd = new Date(mTo.getTime() + eventDuration);
            if (eventEnd > windowEnd) return null;
          }
        }
      }
      return { start: eventStart, end: eventEnd };
    },
    [findResourceById],
  );

  useEffect(() => {
    allResources.forEach((group) => {
      group.children.forEach((resource) => {
        if (resource.maintenanceFrom) resource.maintenanceFrom = new Date(resource.maintenanceFrom as string);
        if (resource.maintenanceTo) resource.maintenanceTo = new Date(resource.maintenanceTo as string);
      });
    });
    addActualEvents(myEventsRef.current);
    const fResources = computeFilteredResources(undefined, '');
    setFilteredResources(fResources);
    setMyEvents([...myEventsRef.current]);
    const today = todayRef.current;
    const end = new Date(today.getTime() + 6 * 24 * 60 * 60 * 1000);
    setCalendarRangeValue([today, end]);
    setRangeLabel(formatDate('MMM D', today) + ' – ' + formatDate('MMM D, YYYY', end));
    refreshJobList(undefined, []);
    const interval = setInterval(() => {
      const currentNow = new Date();
      nowRef.current = currentNow;
      addActualEvents(myEventsRef.current);
      setMyEvents([...myEventsRef.current]);
    }, 60000);
    return () => clearInterval(interval);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const handleZoom = useCallback(
    (zoom: number) => {
      setZoomLevel(zoom);
      setCalView(buildViewConfig(currentRangeDaysRef.current));
    },
    [currentRangeDaysRef],
  );

  const applyDaysRange = useCallback(
    (days: number, startDate?: Date) => {
      const target = startDate || todayRef.current;
      currentViewStartRef.current = target;
      currentViewEndRef.current = new Date(target.getTime() + days * 24 * 60 * 60 * 1000);
      setRefDate(target);
      setCalView(buildViewConfig(days));
      const fResources = computeFilteredResources();
      setFilteredResources(fResources);
      const navigateTo = todayRef.current >= target && todayRef.current < currentViewEndRef.current ? nowRef.current : target;
      setTimeout(() => calendarRef.current?.navigate(navigateTo), 100);
    },
    [computeFilteredResources, todayRef, nowRef, currentViewStartRef, currentViewEndRef, calendarRef],
  );

  const applyPendingRange = useCallback(() => {
    currentRangeStartRef.current = pendingRangeStartRef.current;
    currentRangeDaysRef.current = pendingRangeDaysRef.current;
    applyDaysRange(currentRangeDaysRef.current, currentRangeStartRef.current);
    const start = currentRangeStartRef.current;
    const days = currentRangeDaysRef.current;
    const end = new Date(start.getTime() + (days - 1) * 24 * 60 * 60 * 1000);
    setRangeLabel(formatDate('MMM D', start) + ' – ' + formatDate('MMM D, YYYY', end));
    refreshJobList();
    rangePopupRef.current?.close();
  }, [applyDaysRange, refreshJobList]);

  const invalidateResources = useCallback(
    (event: DispatchEvent): number | null => {
      const now = new Date();
      nowRef.current = now;
      const minTime = new Date(now.getTime() + 2 * 60 * 60 * 1000);
      const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
      const invalidIds: (number | string)[] = [];
      const validIds: number[] = [];
      const windowStart = new Date(event.pickup![0] as string);
      const windowEnd = new Date(event.drop![1] as string);
      const allCalendarEvents = calendarRef.current?.getEvents() || [];
      for (const group of allResources) {
        for (const truck of group.children) {
          let isValid = true;
          if ((truck.capacity || 0) < (event.size || 0)) {
            truck.eventCreation = false;
            invalidIds.push(truck.id as number);
            isValid = false;
          }
          if (isValid) {
            const truckEvents = allCalendarEvents.filter((ev: DispatchEvent) => ev.resource === truck.id);
            const overlappingEvent = truckEvents.find((ev: DispatchEvent) => {
              const evEnd = ev.drop ? new Date(ev.drop[1] as string) : new Date(ev.end as string);
              return evEnd > windowStart && new Date(ev.start as string) < windowEnd;
            });
            if (overlappingEvent && event.resource !== truck.id) {
              truck.eventCreation = false;
              invalidIds.push(truck.id as number);
              if (!String(truck.id).includes('actual')) invalidIds.push(truck.id + '-actual');
              isValid = false;
            }
          }
          if (isValid && !String(truck.id).includes('actual')) validIds.push(truck.id as number);
        }
      }
      myInvalidsRef.current = [
        { start: todayRef.current, end: windowStart < minTime ? minTime : windowStart, cssClass: 'mds-dispatch-disabled-row' },
        {
          start: windowStart < minTime ? minTime : windowStart,
          end: windowEnd,
          resource: invalidIds,
          cssClass: 'mds-dispatch-disabled-row',
        },
        { start: windowEnd, end: sevenDaysFromNow, cssClass: 'mds-dispatch-disabled-row' },
      ];
      setCalendarInvalid([...myInvalidsRef.current, ...maintenanceInvalids]);
      return validIds.length > 0 ? validIds[0] : null;
    },
    [maintenanceInvalids, nowRef, todayRef, calendarRef, myInvalidsRef],
  );

  const resetEventCreationFlags = useCallback(() => {
    for (const group of allResources) {
      for (const truck of group.children) {
        if (!String(truck.id).includes('actual')) truck.eventCreation = true;
      }
    }
  }, []);

  const onEventCreated = useCallback(
    (args: MbscEventCreatedEvent) => {
      const event = args.event as DispatchEvent;
      if (args.action === 'externalDrop') {
        scheduledJobIdsRef.current = [...scheduledJobIdsRef.current, event.id as number];
        const scheduledEvent = { ...event, status: 'scheduled', color: statusColors['scheduled'] };
        calendarRef.current?.updateEvent(scheduledEvent);
        myEventsRef.current = [...myEventsRef.current, scheduledEvent];
        setMyEvents([...myEventsRef.current]);
        setJobListItems((prev) => prev.filter((item) => item.type !== 'job' || item.job!.id !== event.id));
      }
      showToast(event.from + ' → ' + event.to + ' added');
    },
    [showToast],
  );

  const onEventCreateFailed = useCallback(
    (args: MbscEventCreateFailedEvent) => {
      const draggedEvent = args.event as DispatchEvent;
      const slot = findFirstSlot(draggedEvent);
      if (slot) {
        draggedEvent.start = slot.start;
        draggedEvent.end = slot.end;
        calendarRef.current?.addEvent(draggedEvent);
        if (args.action === 'externalDrop') {
          scheduledJobIdsRef.current = [...scheduledJobIdsRef.current, draggedEvent.id as number];
          setJobListItems((prev) => prev.filter((item) => item.type !== 'job' || item.job!.id !== draggedEvent.id));
        }
        setTimeout(() => calendarRef.current?.navigateToEvent({ start: draggedEvent.start, resource: draggedEvent.resource }));
        showToast(draggedEvent.from + ' → ' + draggedEvent.to + ' added to first available slot');
      } else {
        showToast('No available slot found for ' + draggedEvent.from + ' → ' + draggedEvent.to);
      }
    },
    [findFirstSlot, showToast],
  );

  const onEventUpdateFailed = useCallback(
    (args: MbscEventUpdateFailedEvent) => {
      const draggedEvent = args.event as DispatchEvent;
      const slot = findFirstSlot(draggedEvent);
      if (slot) {
        draggedEvent.start = slot.start;
        draggedEvent.end = slot.end;
        calendarRef.current?.updateEvent(draggedEvent);
        setTimeout(() => calendarRef.current?.navigateToEvent({ start: draggedEvent.start, resource: draggedEvent.resource }));
        showToast(draggedEvent.from + ' → ' + draggedEvent.to + ' moved to first available slot');
      } else {
        const originalEvent = args.oldEvent as DispatchEvent;
        setTimeout(() => calendarRef.current?.navigateToEvent({ start: originalEvent.start, resource: originalEvent.resource }));
        showToast('No available slot found for ' + draggedEvent.from + ' → ' + draggedEvent.to);
      }
    },
    [findFirstSlot, showToast],
  );

  const onEventDragStart = useCallback(
    (args: MbscEventDragEvent) => {
      const event = args.event as DispatchEvent;
      const resourceToNavigate = invalidateResources(event);
      if (!event.resource) {
        const slot = findFirstSlot(event);
        if (resourceToNavigate && slot) {
          setTimeout(() => calendarRef.current?.navigateToEvent({ start: slot.start, resource: resourceToNavigate }));
        } else {
          showToast('No available slot for: ' + event.from + ' → ' + event.to);
        }
      }
    },
    [invalidateResources, findFirstSlot, showToast],
  );

  const onEventDragEnd = useCallback(() => {
    resetEventCreationFlags();
    myInvalidsRef.current = [];
    const fResources = computeFilteredResources();
    setFilteredResources(fResources);
    setCalendarInvalid([...maintenanceInvalids]);
  }, [resetEventCreationFlags, computeFilteredResources, maintenanceInvalids]);

  const onEventClick = useCallback(
    (args: MbscEventClickEvent) => {
      const ev = args.event as DispatchEvent;
      if (ev.status === 'actual') {
        const actualStart = new Date(ev.start as string);
        const scheduledDeparture = new Date(ev.pickup![0] as string);
        const departureDelay = Math.round((actualStart.getTime() - scheduledDeparture.getTime()) / 60000);
        const formatDelay = (min: number) => {
          if (Math.abs(min) < 5) return 'on time';
          if (min > 0) return min + ' min late';
          return Math.abs(min) + ' min early';
        };
        const formatDuration = (ms: number) => {
          const t = Math.round(ms / 60000);
          const h = Math.floor(t / 60);
          const m = t % 60;
          if (h === 0) return m + ' min';
          if (m === 0) return h + ' h';
          return h + ' h ' + m + ' min';
        };
        let msg: string;
        if (ev.title === 'In progress') {
          msg =
            'Actual transport: departed ' +
            formatDelay(departureDelay) +
            ' · running ' +
            formatDuration(new Date().getTime() - actualStart.getTime());
        } else {
          const actualEnd = new Date(ev.end as string);
          const scheduledArrival = new Date(ev.drop![0] as string);
          const arrivalDelay = Math.round((actualEnd.getTime() - scheduledArrival.getTime()) / 60000);
          msg = 'Actual transport: departed ' + formatDelay(departureDelay) + ' · arrived ' + formatDelay(arrivalDelay);
        }
        showToast(msg);
      }
    },
    [showToast],
  );

  const onPageLoaded = useCallback(
    (args: MbscPageLoadedEvent) => {
      currentViewStartRef.current = args.firstDay as Date;
      currentViewEndRef.current = args.lastDay as Date;
    },
    [currentViewStartRef, currentViewEndRef],
  );

  const renderResource = useCallback(
    (resource: TruckResource) => {
      const mStatus = getResourceMaintenanceStatus(resource, nowRef.current, currentViewStartRef.current, currentViewEndRef.current);
      return (
        <div>
          <div className="mds-dispatch-name">
            {resource.name}
            {resource.name && resource.plate && <span className="mds-dispatch-plate">{resource.plate}</span>}
          </div>
          {!resource.isParent && resource.name && (
            <div className="mds-dispatch-status">
              <span className="mds-dispatch-status-dot" style={{ backgroundColor: maintenanceColors[mStatus] }}></span>
              {maintenanceLabels[mStatus]}
            </div>
          )}
        </div>
      );
    },
    [nowRef, currentViewStartRef, currentViewEndRef],
  );

  const handleSearch = useCallback(
    (q: string) => {
      searchQueryRef.current = q;
      setTimeout(() => {
        const fResources = computeFilteredResources(undefined, q);
        setFilteredResources(fResources);
      }, 300);
    },
    [computeFilteredResources],
  );

  const handleFilterOpen = useCallback((e: MouseEvent<HTMLButtonElement>) => {
    setTempFilters({ ...filtersRef.current });
    setFilterAnchor(e.currentTarget as HTMLElement);
    setTimeout(() => filterPopupRef.current?.open());
  }, []);

  const renderResourceEmpty = useCallback(
    () => (
      <div className="mds-dispatch-empty mbsc-flex mbsc-align-items-center">
        <div className="mbsc-flex-1-1">
          <img src="https://img.mobiscroll.com/demos/filter-no-result.png" alt="Empty list" style={{ width: 100 }} />
          <p className="mbsc-font mbsc-margin mbsc-medium mbsc-italic mbsc-txt-muted">No resources match your search.</p>
          <p className="mbsc-margin mbsc-medium mbsc-italic mbsc-txt-muted">Adjust your filters or try a different keyword.</p>
          <Button
            variant="outline"
            onClick={() => {
              searchQueryRef.current = '';
              setResetKey((k) => k + 1);
              const newFilters: Filters = { operational: true, maintenance: true };
              allResources.forEach((cat) => {
                newFilters[cat.id as string] = true;
              });
              setFilters(newFilters);
              const fResources = computeFilteredResources(newFilters, '');
              setFilteredResources(fResources);
              refreshJobList(newFilters);
              showToast('Filters cleared');
            }}
          >
            Reset Filters
          </Button>
        </div>
      </div>
    ),
    [computeFilteredResources, refreshJobList, showToast],
  );

  const renderResourceHeader = useCallback(
    () => <ResourceHeader key={resetKey} onSearch={handleSearch} onFilterOpen={handleFilterOpen} />,
    [handleSearch, handleFilterOpen, resetKey],
  );

  const renderTimelineEventContent = useCallback((data: MbscCalendarEventData) => {
    const job = data.original as DispatchEvent;
    if (job.status === 'actual') {
      return job.title === 'In progress' ? <span className="mds-dispatch-actual-label">In progress</span> : null;
    }
    return (
      <div className="mds-dispatch-event-content-wrapper mbsc-flex">
        <span className="mds-dispatch-event-route mbsc-flex-1-1">
          {job.from} → {job.to}
        </span>
        <span className="mds-dispatch-event-planned-badge">
          <span className="mds-dispatch-event-planned-badge-text">Planned</span>
        </span>
      </div>
    );
  }, []);

  const calendarData = useMemo(() => getStatusFilteredEvents(myEvents, statusFilters), [myEvents, statusFilters, getStatusFilteredEvents]);

  const rangeSelectData = useMemo(
    () => [
      { value: 'custom', text: 'Custom' },
      { value: '7', text: 'Next 7 days' },
      { value: '14', text: 'Next 14 days' },
      { value: '30', text: 'Next 30 days' },
      { value: '60', text: 'Next 60 days' },
      { value: '90', text: 'Next 90 days' },
    ],
    [],
  );

  const rangePopupResponsive = useMemo(
    () => ({
      xsmall: {
        display: 'bottom' as const,
        touchUi: true,
        buttons: [
          { text: 'Apply', keyCode: 'enter' as const, handler: applyPendingRange, cssClass: 'mbsc-popup-button-primary' },
          'cancel',
        ],
      },
      custom: {
        breakpoint: 575,
        buttons: [] as MbscPopupButton[],
        display: 'anchored' as const,
        touchUi: false,
        scrollLock: false,
        maxWidth: 920,
      },
    }),
    [applyPendingRange],
  );

  const filterPopupButtons = useMemo<(string | MbscPopupButton)[]>(
    () => [
      'cancel',
      {
        text: 'Apply',
        keyCode: 'enter',
        cssClass: 'mbsc-popup-button-primary',
        handler: () => {
          setFilters({ ...tempFilters });
          const fResources = computeFilteredResources(tempFilters);
          setFilteredResources(fResources);
          refreshJobList(tempFilters);
          filterPopupRef.current?.close();
          showToast('Filters applied');
        },
      },
    ],
    [tempFilters, computeFilteredResources, refreshJobList, showToast],
  );

  return (
    <Page className="mds-dispatch-page">
      <div className="mds-dispatch-outer mbsc-flex-col">
        <div className="mds-dispatch-custom-header mbsc-flex">
          <Button variant="flat" onClick={() => rangePopupRef.current?.open()}>
            <span className="mds-dispatch-range-label">{rangeLabel}</span>
          </Button>
          <div className="mds-dispatch-header-right mbsc-flex">
            <SegmentedGroup cssClass="mds-dispatch-status-filter" select="multiple">
              <Segmented
                value="scheduled"
                checked={statusFilters.includes('scheduled')}
                onChange={(e: ChangeEvent<HTMLInputElement>) => {
                  if (e.target.checked) setStatusFilters((sf) => [...sf, 'scheduled']);
                  else setStatusFilters((sf) => sf.filter((s) => s !== 'scheduled'));
                }}
              >
                <span className="mds-dispatch-seg-dot mds-dispatch-seg-dot-scheduled"></span>Scheduled
              </Segmented>
              <Segmented
                value="in progress"
                checked={statusFilters.includes('in progress')}
                onChange={(e: ChangeEvent<HTMLInputElement>) => {
                  if (e.target.checked) setStatusFilters((sf) => [...sf, 'in progress']);
                  else setStatusFilters((sf) => sf.filter((s) => s !== 'in progress'));
                }}
              >
                <span className="mds-dispatch-seg-dot mds-dispatch-seg-dot-inprogress"></span>In progress
              </Segmented>
              <Segmented
                value="completed"
                checked={statusFilters.includes('completed')}
                onChange={(e: ChangeEvent<HTMLInputElement>) => {
                  if (e.target.checked) setStatusFilters((sf) => [...sf, 'completed']);
                  else setStatusFilters((sf) => sf.filter((s) => s !== 'completed'));
                }}
              >
                <span className="mds-dispatch-seg-dot mds-dispatch-seg-dot-completed"></span>Completed
              </Segmented>
            </SegmentedGroup>
            <div className="mds-dispatch-zoom mbsc-flex">
              <Button icon="minus" variant="flat" disabled={zoomLevel === 1} onClick={() => handleZoom(zoomLevel - 1)} />
              <input
                type="range"
                min="1"
                max="5"
                value={zoomLevel}
                className="mds-dispatch-zoom-slider"
                onChange={(e) => handleZoom(+e.target.value)}
              />
              <Button icon="plus" variant="flat" disabled={zoomLevel === 5} onClick={() => handleZoom(zoomLevel + 1)} />
            </div>
            <Button
              variant="outline"
              startIcon="clock"
              className="mds-dispatch-now-btn"
              onClick={() => {
                const now = new Date();
                nowRef.current = now;
                todayRef.current = new Date(now.getFullYear(), now.getMonth(), now.getDate());
                currentRangeStartRef.current = todayRef.current;
                applyDaysRange(currentRangeDaysRef.current, todayRef.current);
                const start = todayRef.current;
                const days = currentRangeDaysRef.current;
                const end = new Date(start.getTime() + (days - 1) * 24 * 60 * 60 * 1000);
                setRangeLabel(formatDate('MMM D', start) + ' – ' + formatDate('MMM D, YYYY', end));
                setTimeout(() => calendarRef.current?.navigate(now));
                refreshJobList();
              }}
            >
              Now
            </Button>
          </div>
        </div>
        <div className="mbsc-grid mbsc-no-padding mds-dispatch-content">
          <div className="mbsc-row mds-dispatch-full-height">
            <div className="mbsc-col-sm-3 mds-dispatch-full-height mds-dispatch-wrapper">
              <div className="mbsc-flex-col mbsc-flex-1-0 mbsc-padding">
                {jobListItems.length === 0 && (
                  <p className="mbsc-font mbsc-margin mbsc-medium mbsc-italic mbsc-txt-muted">No jobs for this period.</p>
                )}
                {jobListItems.map((item) =>
                  item.type === 'header' ? (
                    <div key={item.key} className="mds-dispatch-job-day-header">
                      {item.label}
                    </div>
                  ) : (
                    <JobCard key={item.key} job={item.job!} />
                  ),
                )}
              </div>
            </div>
            <div className="mbsc-col-sm-9 mds-dispatch-column mds-dispatch-full-height">
              <Eventcalendar
                ref={calendarRef}
                clickToCreate={false}
                dragToCreate={false}
                dragToResize={false}
                dragToMove={true}
                externalDrop={true}
                eventOverlap={false}
                cssClass="mds-dispatch-calendar"
                refDate={refDate}
                zoomLevel={zoomLevel}
                view={calView}
                data={calendarData}
                resources={filteredResources}
                invalid={calendarInvalid}
                renderResource={renderResource}
                renderResourceEmpty={renderResourceEmpty}
                renderResourceHeader={renderResourceHeader}
                renderTimelineEventContent={renderTimelineEventContent}
                onEventCreated={onEventCreated}
                onEventCreateFailed={onEventCreateFailed}
                onEventUpdateFailed={onEventUpdateFailed}
                onEventDragStart={onEventDragStart}
                onEventDragEnd={onEventDragEnd}
                onEventClick={onEventClick}
                onPageLoaded={onPageLoaded}
              />
            </div>
          </div>
        </div>

        <Popup
          ref={filterPopupRef}
          buttons={filterPopupButtons}
          contentPadding={false}
          display="anchored"
          focusOnClose={false}
          focusOnOpen={false}
          showOverlay={false}
          width={400}
          anchor={filterAnchor ?? undefined}
        >
          <div className="mbsc-form-group">
            <div className="mbsc-form-group-title">Capacity</div>
            {allResources.map((res) => (
              <Checkbox
                key={res.id as string}
                label={res.name}
                checked={!!tempFilters[res.id as string]}
                onChange={(e: ChangeEvent<HTMLInputElement>) => setTempFilters((tf) => ({ ...tf, [res.id as string]: e.target.checked }))}
              />
            ))}
          </div>
          <div className="mbsc-form-group">
            <div className="mbsc-form-group-title">Operational Status</div>
            <Checkbox
              label="In maintenance/Maintenance planned"
              checked={!!tempFilters['maintenance']}
              onChange={(e: ChangeEvent<HTMLInputElement>) => setTempFilters((tf) => ({ ...tf, maintenance: e.target.checked }))}
            />
            <Checkbox
              label="Operational"
              checked={!!tempFilters['operational']}
              onChange={(e: ChangeEvent<HTMLInputElement>) => setTempFilters((tf) => ({ ...tf, operational: e.target.checked }))}
            />
          </div>
        </Popup>

        <Popup ref={rangePopupRef} responsive={rangePopupResponsive}>
          <div className="mds-dispatch-range-popup">
            <div className="mbsc-grid mbsc-no-padding">
              <div className="mbsc-row">
                <div className="mbsc-col-sm-4 mbsc-push-sm-8 mbsc-flex mbsc-flex-col">
                  <div className="mds-dispatch-range-inputs">
                    <Select
                      label="Date range"
                      labelStyle="stacked"
                      inputStyle="box"
                      data={rangeSelectData}
                      value={rangeSelectValue}
                      onChange={(args: MbscSelectChangeEvent) => {
                        const val = args.value as string;
                        setRangeSelectValue(val);
                        if (val === 'custom') {
                          setRangeInputsDisabled(false);
                        } else {
                          setRangeInputsDisabled(true);
                          pendingRangeDaysRef.current = +val;
                          pendingRangeStartRef.current = todayRef.current;
                          const end = new Date(todayRef.current.getTime() + (+val - 1) * 24 * 60 * 60 * 1000);
                          setCalendarRangeValue([todayRef.current, end]);
                        }
                      }}
                    />
                    <Input
                      ref={rangeStartRef}
                      label="Start"
                      labelStyle="stacked"
                      inputStyle="box"
                      className="mds-dispatch-range-input"
                      disabled={rangeInputsDisabled}
                    />
                    <Input
                      ref={rangeEndRef}
                      label="End"
                      labelStyle="stacked"
                      inputStyle="box"
                      className="mds-dispatch-range-input"
                      disabled={rangeInputsDisabled}
                    />
                  </div>
                  <div className="mds-dispatch-range-desktop-btns mbsc-button-group-justified">
                    <Button onClick={applyPendingRange}>Apply</Button>
                    <Button onClick={() => rangePopupRef.current?.close()}>Cancel</Button>
                  </div>
                </div>
                <div className="mbsc-col-sm-8 mbsc-pull-sm-4">
                  <Datepicker
                    display="inline"
                    controls={['calendar']}
                    select="range"
                    showRangeLabels={false}
                    pages="auto"
                    showOnClick={false}
                    showOnFocus={false}
                    value={calendarRangeValue}
                    startInput={rangeStartInput}
                    endInput={rangeEndInput}
                    onChange={(args: MbscDatepickerChangeEvent) => {
                      const dates = args.value as [Date | null, Date | null] | null;
                      if (dates) {
                        setCalendarRangeValue(dates as [Date, Date]);
                        setRangeInputsDisabled(false);
                        setRangeSelectValue('custom');
                        if (dates[0] && dates[1]) {
                          const start = new Date(dates[0]);
                          const end = new Date(dates[1]);
                          pendingRangeStartRef.current = start;
                          pendingRangeDaysRef.current = Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1;
                        }
                      }
                    }}
                  />
                </div>
              </div>
            </div>
          </div>
        </Popup>

        <Toast message={toastMessage} isOpen={isToastOpen} onClose={() => setIsToastOpen(false)} />
      </div>
    </Page>
  );
}

export default App;
```

### CSS

```css
.mds-dispatch-calendar .mbsc-calendar-header {
  display: none;
}
.mds-dispatch-calendar .mbsc-timeline-resource-header {
  height: 100%;
  padding: 8px;
  box-sizing: border-box;
}
.mds-dispatch-calendar .mbsc-timeline-resource-col {
  width: 300px;
}
.mds-dispatch-calendar .mbsc-timeline-resource-title {
  height: 100%;
  box-sizing: border-box;
}
.mds-dispatch-calendar .mbsc-timeline-row:not(.mds-dispatch-actual-resource):not(.mbsc-timeline-parent) {
  height: 58px;
}
.mds-dispatch-calendar .mds-dispatch-actual-resource {
  height: 8px !important;
  min-height: 0 !important;
}
.mbsc-timeline-row:has(+ .mds-dispatch-actual-resource) {
  border-bottom: none;
}
.mds-dispatch-actual-resource .mbsc-timeline-event {
  height: 10px;
  margin-top: -7px;
}
.mds-dispatch-actual-resource .mbsc-timeline-event-background {
  margin: 0;
}
.mds-dispatch-calendar .mbsc-timeline-parent {
  height: 34px;
}
.mds-dispatch-calendar .mbsc-timeline-parent .mbsc-timeline-invalid {
  display: none;
}
.mds-dispatch-calendar .mbsc-timeline-parent .mbsc-timeline-column {
  border: 0;
}
.mds-dispatch-calendar .mbsc-timeline-row-gutter {
  height: 6px;
}
.mds-dispatch-search .mbsc-textfield-wrapper {
  margin: 0;
}
.mds-dispatch-search .mbsc-textfield-wrapper.mbsc-ltr {
  margin-right: 8px;
}
.mds-dispatch-search .mbsc-textfield-wrapper.mbsc-rtl {
  margin-left: 8px;
}
.mds-dispatch-search .mbsc-textfield-wrapper.mbsc-material {
  margin-top: 2px;
}
.mds-dispatch-search .mbsc-textfield {
  height: 36px;
  padding: 0 8px 0 38px;
}
.mds-dispatch-search .mbsc-textfield-icon {
  top: 50%;
  left: 8px;
  font-size: 20px;
  height: 24px;
  line-height: 24px;
  margin-top: -12px;
}
.mds-dispatch-search .mbsc-button {
  margin: 0;
  height: 36px;
  font-size: 14px;
}
.mds-dispatch-actual-event .mbsc-schedule-event-range,
.mds-dispatch-actual-event .mbsc-schedule-event-bar,
.mds-dispatch-actual-event .mbsc-schedule-event-title,
.mds-dispatch-actual-event .mbsc-schedule-event-time,
.mds-dispatch-actual-event .mbsc-timeline-event-time {
  display: none;
}
.mds-dispatch-disabled-row.mbsc-schedule-invalid {
  background: rgba(128, 128, 128, 0.2);
}
.mds-dispatch-maintenance-invalid.mbsc-schedule-invalid {
  background: repeating-linear-gradient(
    -45deg,
    rgba(128, 128, 128, 0.12),
    rgba(128, 128, 128, 0.12) 11px,
    rgba(128, 128, 128, 0.28) 11px,
    rgba(128, 128, 128, 0.28) 22px
  );
}
.mds-dispatch-pulse:not(.mbsc-schedule-event-hover) .mbsc-timeline-event-background {
  box-shadow: 0 0 0 rgba(249, 115, 22, 0.4);
  animation: pulse 2s infinite;
}
.mds-dispatch-now-btn.mbsc-ios {
  padding-top: 0;
  padding-bottom: 0;
}
.mds-dispatch-seg-dot {
  content: '';
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin-right: 5px;
  vertical-align: middle;
}
.mds-dispatch-seg-dot-scheduled {
  background: #2196f3;
}
.mds-dispatch-seg-dot-inprogress {
  background: #f97316;
}
.mds-dispatch-seg-dot-completed {
  background: #78909c;
}
.mbsc-ios-dark .mds-dispatch-custom-header,
.mbsc-material-dark .mds-dispatch-custom-header,
.mbsc-ios-dark .mds-dispatch-wrapper,
.mbsc-material-dark .mds-dispatch-wrapper {
  background-color: #000;
}
.mbsc-windows-dark .mds-dispatch-custom-header,
.mbsc-windows-dark .mds-dispatch-wrapper {
  background-color: #1f1f1f;
}
.mds-dispatch-range-popup .mbsc-datepicker-inline {
  border: none;
  border-radius: 20px;
  overflow: hidden;
}
.mds-dispatch-range-popup .mbsc-textfield-wrapper-box {
  margin-top: 0;
  margin-right: 0;
}
.mds-dispatch-range-desktop-btns.mbsc-button-group-justified,
.mds-dispatch-range-desktop-btns.mbsc-button-group-justified button {
  margin-bottom: 0;
}
.mds-dispatch-content > .mbsc-row {
  height: 100%;
}
.mds-dispatch-calendar.mbsc-eventcalendar .mbsc-calendar-wrapper {
  border: none;
}
@media (max-width: 575px) {
  .mds-dispatch-range-desktop-btns {
    display: none;
  }
  .mds-dispatch-range-popup .mbsc-textfield-wrapper-box {
    margin-left: 0;
  }
}
/* --- Demo styles --- */
.mds-dispatch-full-height {
  height: 100%;
}
.mds-dispatch-column {
  border-left: 1px solid #ccc;
}
.mds-dispatch-wrapper {
  overflow: auto;
  background-color: #fff;
}
.mds-dispatch-job-day-header {
  font-family:
    -apple-system,
    Segoe UI,
    Roboto,
    sans-serif;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: #6b7280;
  padding: 10px 2px 4px;
}
.mds-dispatch-job-day-header:first-child {
  padding-top: 2px;
}
.mds-dispatch-jobs {
  align-items: center;
  gap: 8px;
  margin: 4px 0;
  padding: 6px 10px;
  touch-action: none;
  color: #fff;
  background-color: #488dc5;
  border-radius: 8px;
  font-family:
    -apple-system,
    Segoe UI,
    Roboto,
    sans-serif;
  font-size: 12px;
  cursor: grab;
}
.mds-dispatch-job-route {
  flex: 1 1 0;
  min-width: 0;
}
.mds-dispatch-job-stop {
  align-items: center;
  gap: 6px;
}
.mds-dispatch-job-dot {
  flex: 0 0 8px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  border: 2px solid #fff;
}
.mds-dispatch-job-dot-origin {
  background: transparent;
}
.mds-dispatch-job-dot-dest {
  background: #fff;
}
.mds-dispatch-job-addr {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 1.5;
}
.mds-dispatch-job-connector {
  width: 2px;
  height: 5px;
  background: rgba(255, 255, 255, 0.4);
  margin-left: 3px;
}
.mds-dispatch-job-meta {
  align-items: flex-end;
  gap: 3px;
  font-size: 11px;
  opacity: 0.9;
}
.mds-dispatch-job-size {
  background: rgba(255, 255, 255, 0.2);
  border-radius: 10px;
  padding: 1px 7px;
  font-weight: 600;
}
.mds-dispatch-actual-event {
  height: 20px;
}
.mds-dispatch-actual-label {
  font-size: 9px;
  font-weight: 600;
  white-space: nowrap;
  opacity: 0.9;
  line-height: 1;
}
@keyframes pulse {
  0% {
    box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.4);
  }
  70% {
    box-shadow: 0 0 0 8px rgba(249, 115, 22, 0);
  }
  100% {
    box-shadow: 0 0 0 0 rgba(249, 115, 22, 0);
  }
}
.mds-dispatch-event-content-wrapper {
  width: 100%;
  height: 100%;
  align-items: center;
  gap: 5px;
  overflow: hidden;
}
.mds-dispatch-event-route {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.mds-dispatch-event-planned-badge {
  flex-shrink: 100;
  min-width: 0;
  overflow: hidden;
}
.mds-dispatch-event-planned-badge-text {
  font-size: 10px;
  padding: 0 5px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.3);
  white-space: nowrap;
  display: block;
}
.mds-dispatch-search {
  height: 100%;
}
.mds-dispatch-name {
  font-size: 14px;
  font-weight: bold;
  margin-bottom: 5px;
}
.mds-dispatch-plate {
  border: 1px solid #488dc5;
  border-radius: 4px;
  margin: 0 5px;
  padding: 0 5px;
}
.mds-dispatch-status-dot {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  margin-right: 5px;
}
.mds-dispatch-status {
  font-size: 13px;
  font-weight: normal;
}
.mds-dispatch-empty {
  height: 100%;
  text-align: center;
}
.mds-dispatch-header-right {
  align-items: center;
  gap: 16px;
  margin-left: auto;
}
.mds-dispatch-status-filter {
  width: 300px;
}
.mds-dispatch-zoom {
  align-items: center;
  gap: 6px;
}
.mds-dispatch-zoom-slider {
  width: 80px;
  cursor: pointer;
}
.mds-dispatch-outer {
  height: 100%;
}
.mds-dispatch-custom-header {
  align-items: center;
  flex-shrink: 0;
  height: 52px;
  padding: 0 12px;
  border-bottom: 1px solid #ccc;
  gap: 12px;
  background-color: #fff;
}
.mds-dispatch-range-label {
  font-size: 20px;
  font-weight: bold;
}
.mds-dispatch-range-inputs {
  flex: 1;
}
.mds-dispatch-content {
  flex: 1 1 0;
  min-height: 0;
}
.mds-dispatch-page {
  height: 100%;
}
```

## Implementation notes

- Use `type: 'day'` with a `size` equal to the active day count, `timeCellStep: 60`, `timeLabelStep: 60`, `eventHeight: 'variable'`, and a `zoomLevels` map (keys 1-5) where each level sets a `columnWidth` (`'xsmall'` through `'xlarge'`) and its own `timeCellStep`/`timeLabelStep` values. Pass the active `zoomLevel` state as a prop to the `Eventcalendar`.
- Define resources as a two-level tree of capacity tier groups (`eventCreation: false`) with truck leaf nodes beneath them. For each truck, add a parallel `{id}-actual` sibling row (`eventCreation: false`, custom `cssClass`) to display live trip progress separately from the plan. Give each truck custom `capacity`, `plate`, and optional `maintenanceFrom`/`maintenanceTo` properties.
- Store each transport job with `from`, `to`, `size`, `pickup: [earliest, latest]`, `drop: [earliest, latest]`, and `status`. Derive `start = pickup[0]`, `end = drop[0]`, and `title` from the route. Keep scheduled events in a `myEventsRef` and unscheduled sidebar jobs in a separate `externalEventsRef`.
- In `addActualEvents`, generate a companion event on the `{id}-actual` row for every in-progress trip (set `end` to `now`, add a pulse CSS class) and every completed trip. Call this function on mount and repeat every 60 seconds in a `setInterval`.
- Render each sidebar job card with a ref-callback to obtain the DOM element, then mount a `Draggable` with `dragData={job}` and `element={elem}`. Filter the sidebar list to jobs within the current date range, whose cargo fits the maximum visible truck capacity, that have not yet been scheduled, and whose pickup is at least 2 hours from now.
- Set `externalDrop={true}`, `dragToMove={true}`, and `eventOverlap={false}` on the `Eventcalendar`. Disable `clickToCreate`, `dragToCreate`, and `dragToResize`.
- In `onEventDragStart`, set `eventCreation: false` on every truck that is over-capacity or has an overlapping scheduled event within the dragged job's `pickup[0]`-`drop[1]` window. Build three `invalid` segments: the period before the pickup window (all resources), the pickup-to-drop window restricted to the invalid truck IDs, and the period after the drop window. Combine these with the static maintenance invalids when setting `calendarInvalid`.
- In `onEventDragEnd`, reset `eventCreation: true` on all trucks, clear the drag-time invalids, and restore `calendarInvalid` to maintenance invalids only.
- In `onEventCreated`, only act when `args.action === 'externalDrop'`: add the job ID to `scheduledJobIdsRef`, apply the scheduled color and status by replacing the event in place in the array bound to `data` (the `Eventcalendar` React ref only exposes `getEvents`/`getInvalids`/`getViewDate`/`navigateToEvent` - it has no `updateEvent`/`addEvent`; JS/jQuery: call `inst.updateEvent()` for the imperative-only equivalent), remove the job from the sidebar list, and show a `Toast`.
- In both `onEventCreateFailed` and `onEventUpdateFailed`, call a shared `findFirstSlot` function that searches within `pickup[0]`-`drop[1]`, advancing past any truck maintenance window. If a slot is found, splice/replace the job into the array bound to `data` (add for create, replace for update) - the React ref exposes no `addEvent`/`updateEvent`; JS/jQuery: call `inst.addEvent()`/`inst.updateEvent()` for the imperative-only equivalent - then call `navigateToEvent` (this method IS available on the React ref) and show a success `Toast`; if none is found, show an error `Toast` (and for `onEventUpdateFailed`, navigate back to the original event position).
- Use `renderTimelineEventContent` (Angular: `timelineEventContentTemplate`, Vue: `#timelineEventContent` slot) to render event bodies: show a route string and a "Planned" badge for scheduled events, a pulsing "In progress" label for actual in-progress events, and `null` for completed actual events. In `onEventClick`, show a `Toast` with departure delay and elapsed or arrival time when the clicked event has `status: 'actual'`.
- Use `renderResource` (Angular: `resourceTemplate`, Vue: `#resource` slot) to display the truck name, license plate, and a color-coded maintenance status dot for leaf nodes (operational/maintenance-planned/in-maintenance). Use `renderResourceHeader` (Angular: `resourceHeaderTemplate`, Vue: `#resourceHeader` slot) for a search `Input` and a Filter `Button`. Use `renderResourceEmpty` (Angular: `resourceEmptyTemplate`, Vue: `#resourceEmpty` slot) for a no-results placeholder with a Reset Filters button.
- Build the toolbar outside the `Eventcalendar` as a custom header div. Include a flat `Button` showing the current date-range label that opens the range `Popup`, a `SegmentedGroup` with `select="multiple"` for filtering by Scheduled/In Progress/Completed status, a native `<input type="range" min="1" max="5">` flanked by minus and plus `Button` controls for zoom, and a "Now" `Button` that resets `refDate` to today and navigates to the current time.
- Use a `Popup` with a `responsive` config (bottom sheet on mobile, wide anchored popup on desktop) for range selection. Inside, place a `Select` for preset ranges (7/14/30/60/90 days or Custom), a `Datepicker` in range mode, and `Input` fields for custom start and end dates that are disabled unless "Custom" is selected. Apply the selection with an "Apply" button.
- Use a `Popup` anchored to the Filter button for the filter panel. Render two `Checkbox` groups - one for capacity tiers and one for operational status - using a temp filter state: copy active filters on open, commit on Apply, and discard on cancel.

## Run it in your project

1. If not signed up yet, or don't own a license, you can [start a free trial](https://mobiscroll.com/starttrial).

2. Clone the repository:

   ```
   git clone https://github.com/acidb/mobiscroll-demos-react.git
   ```

3. If not installed yet, install the Mobiscroll CLI from NPM:

   ```
   npm install -g @mobiscroll/cli
   ```

4. Config Mobiscroll - open a terminal window in the root directory and run:

   ```
   mobiscroll config react
   ```

5. Install dependencies - open a terminal window in the root directory and run:

   ```
   npm install
   ```

6. Run the project:

   ```
   npm run dev
   ```

On a trial, add `--trial` to the config command: `mobiscroll config react --trial`.

Running a demo locally requires a Mobiscroll trial or a license; setup is per framework at https://mobiscroll.com/docs/react/getting-started/installation

## Related demos

- Previous: [Drag People onto Events](https://demo.mobiscroll.com/react/timeline/drop-assignee-on-event-from-list.md) - Build a team assignment interface on the React Timeline — drag people from an external list onto meetings with conflict validation.
