Overview

  • This React application lets you schedule shifts for multiple locations

  • There are three shifts defined for each day, starting at 12 AM, 8 AM and 4 PM

  • The work schedule displays an overview for each of the locations, where you can see who is assigned to each shift

  • When adding new shift assignments, you can also see the existing assignments for each employee

  • The frontend is a React application built using the React Scheduler component from DayPilot Pro for JavaScript

  • The backend is a REST API application created using PHP (with MySQL database storage)

For an introduction to using the React Scheduler component please see the following tutorial:

See also an ASP.NET Core version of this tutorial:

License

Licensed for testing and evaluation purposes. Please see the license agreement included in the sample project. You can use the source code of the tutorial if you are a licensed user of DayPilot Pro for JavaScript.

React Work Schedule for Multiple Locations

React Work Schedule for Multiple Locations - PHP, MySQL

Each location view displays the shifts for the current work location in the first row. In the rows below, the Scheduler displays available employees.

  • Shifts planned for the selected location are displayed in blue and linked to the assigned person.

  • Each person row displays all shifts for that person, including assignments for other locations (they are displayed in gray).

  • This way you can see all shifts planned for the current location and also availability of all employees.

Location 2

You can see how the view changes after switching to a different location:

  • The first row displays all shifts for “Location 2”.

  • The shifts assigned to “Location 1” are displayed in gray color.

React Shift Schedule for Multiple Locations - PHP, MySQL

React Scheduler Configuration

This is the basic React Scheduler configuration that we will use to build our shift planning application. You can generate a blank React project with a pre-configured Scheduler component at https://builder.daypilot.org.

import React, { useState, useRef } from 'react';
import { DayPilot, DayPilotScheduler } from "daypilot-pro-react";
import { Locations } from "./Locations";

const Scheduler = () => {

  const [scheduler, setScheduler] = useState(null);

  const config = {
    timeHeaders: [
      {groupBy: "Month"}, 
      {groupBy: "Day", format: "dddd M/d/yyyy"}, 
      {groupBy: "Cell"}
    ],
    startDate: DayPilot.Date.today().firstDayOfMonth(),
    days: DayPilot.Date.today().daysInMonth(),
    // ...
  };

  return (
    <div>
      <DayPilotScheduler
        {...config}
        controlRef={setScheduler}
      />
    </div>
  );
}

export default Scheduler;

This basic configuration displays the current month, as defined using the startDate and days properties:

startDate: DayPilot.Date.today().firstDayOfMonth(),
days: DayPilot.Date.today().daysInMonth(),

In the next steps, we will extend this configuration with additional properties and event handlers which will let us customize the behavior.

Define Shifts

In this steps, we will define the shifts by overriding the default timeline generated by the Scheduler component.

In order to use a custom timeline, we switch the Scheduler to a manual timeline mode using the scale property:

const config = {
  // ...
  scale: "Manual",
  // ...
};

Now the Scheduler will use a timeline defined using individual cells in the timeline array:

const [timeline, setTimeline] = useState([]);

// ...

return (
  <div>
    <DayPilotScheduler
      {...config}
      timeline={timeline}
      controlRef={setScheduler}
    />
  </div>
);

We will initialize the timeline during the React component initialization:

useEffect(() => {
  setTimeline(createTimeline());
}, []);

Our createTimeline() function defines the shifts as timeline cells. Each shift has 8 hours and we define three shifts per day:

  • 12 AM - 8 AM

  • 8 AM - 4 PM

  • 4 PM - 12 AM

const createTimeline = () => {
  const days = DayPilot.Date.today().daysInMonth();
  const start = DayPilot.Date.today().firstDayOfMonth();

  const result = [];
  for (let i = 0; i < days; i++) {
    const day = start.addDays(i);
    result.push({
      start: day.addHours(0),
      end: day.addHours(8)
    });
    result.push({
      start: day.addHours(8),
      end: day.addHours(16)
    });
    result.push({
      start: day.addHours(16),
      end: day.addHours(24)
    });
  }
  return result;

}

Our updated Scheduler configuration now uses the generated timeline with custom shifts:

import React, {useEffect, useRef, useState} from 'react';
import {DayPilot, DayPilotScheduler} from "daypilot-pro-react";

const Scheduler = () => {

  const [timeline, setTimeline] = useState([]);
  const [scheduler, setScheduler] = useState(null);

  const createTimeline = () => {
    const days = DayPilot.Date.today().daysInMonth();
    const start = DayPilot.Date.today().firstDayOfMonth();

    const result = [];
    for (let i = 0; i < days; i++) {
      const day = start.addDays(i);
      result.push({
        start: day.addHours(0),
        end: day.addHours(8)
      });
      result.push({
        start: day.addHours(8),
        end: day.addHours(16)
      });
      result.push({
        start: day.addHours(16),
        end: day.addHours(24)
      });
    }
    return result;

  }

  const config = {

    timeHeaders: [
      {groupBy: "Month"}, 
      {groupBy: "Day", format: "dddd M/d/yyyy"}, 
      {groupBy: "Cell"}
    ],
    businessBeginsHour: 0,
    businessEndsHour: 24,
    businessWeekends: true,
    scale: "Manual",
    
    // ...

  };

  useEffect(() => {
    setTimeline(createTimeline());
  }, []);


  return (
    <div>
      <DayPilotScheduler
        {...config}
        timeline={timeline}
        controlRef={setScheduler}
      />
    </div>
  );
}

export default Scheduler;

As this is a plan for 24/7 shift rotation schedule, we override the default business hours to include the entire 24-hour period.

Display Data for a Selected Work Location

Display Data for a Selected Work Location in the React Shift Schedule

We are going to display a drop-down list with available locations above the main Scheduler. It will let us select the location to work with and display the shift overview.

The drop-down list is wrapped in a simple <Location> React component:

return (
  <div>
    <Locations
      onChange={setSelectedLocation}
      data={locations}
      selectedValue={selectedLocation}
    />
    <DayPilotScheduler
      {...config}
      timeline={timeline}
      controlRef={setScheduler}
    />
  </div>
);

When a user selects a different location, the component fires the onChange event. We will handle this event an update the Scheduler to display the shift data for the selected location.

Location.js

import React from 'react';

const Locations = ({ onChange, data = [], selectedValue }) => {
  const handleChange = (ev) => {
    const value = parseInt(ev.target.value, 10);
    const item = data.find(item => item.id === value);
    if (onChange) {
      onChange(item);
    }
  };

  return (
    <div className="toolbar">
      Location:&nbsp;
      <select id="locations" value={selectedValue?.id || ''} onChange={handleChange}>
        {data.map(item => (
          <option key={item.id} value={item.id}>
            {item.name}
          </option>
        ))}
      </select>
    </div>
  );
};

export default Locations;

How to Load Work Shift Data

The onChange event handler of the <Locations> component updates the selectedLocation state variable whenever the selected location changes:

<Locations
  onChange={setSelectedLocation}
  data={locations}
  selectedValue={selectedLocation}
/>

We need to add a useEffect hook that triggers the loadData function on the selectedLocation change:

useEffect(() => {
  if (scheduler && selectedLocation) {
    loadData(selectedLocation);
  }
}, [selectedLocation, scheduler]);

The loadData() method loads rows and shift assignments using two parallel API calls (/api/shift_people.php and /api/shift_assignment.php endpoints). We will use DayPilot.Http.get() to make the HTTP requests.

The two DayPilot.Http.get() calls are merged using Promise.all() - our application waits for both calls to complete and updates the React Scheduler component with the location data.

It is possible to update the Scheduler by storing the row and event data in the config (the Scheduler would detect a change and update itself automatically) but we will use the direct API in the example: The update() call loads the new data for rows, events and links and refreshes the Scheduler as necessary.

const loadData = async (location) => {
  if (!location) {
    return;
  }

  const start = scheduler.visibleStart();
  const end = scheduler.visibleEnd();
  const promisePeople = DayPilot.Http.get(`/api/shift_people.php`);
  const promiseAssignments = DayPilot.Http.get(`/api/shift_assignments.php?location=${location.id}&start=${start}&end=${end}`);
  const [{data: people}, {data: assignments}] = await Promise.all([promisePeople, promiseAssignments]);

  const rows = [
    {id: "L" + location.id, name: location.name, type: "location"},
    ...people
  ];

  const links = [];
  assignments.filter(e => e.type === "location").forEach(e => {
    links.push({
      id: e.id,
      from: e.id,
      to: e.join,
      type: "FinishToFinish",
      layer: "Above",
      color: "#e69138"
    });
  });

  scheduler.update({
    resources: rows,
    events: assignments,
    links
  });
};

Add a Shift Assignment

Create a Shift Assignment in the React Shift Scheduling Application

We will use the onTimeRangeSelected event to handle clicks in the Scheduler grid.

Our onTimeRangeSelected event handler checks the target row first - it’s not possible to create a new assignment in the first row which shows the location assignments. We will display a warning using DayPilot.Modal.alert() that it’s necessary to create the assignment by selecting an employee (one of the rows below the location row).

After asking for confirmation, the React Scheduler creates a new assignment in the database by calling the /api/shift_create.php endpoint using axios.

const config = {
  // ...
  onTimeRangeSelected: async args => {

    const row = scheduler.rows.find(args.resource);
    if (row.index === 0) {
      await DayPilot.Modal.alert("No assignment for this shift.<br><br>Click below to create a new assignment.");
      scheduler.clearSelection();
      return;
    }

    const modal = await DayPilot.Modal.confirm("Create a new assignment?");
    scheduler.clearSelection();
    if (modal.canceled) {
      return;
    }
    const locationId = selectedLocationRef.current.id;

    const {data} = await DayPilot.Http.post("/api/shift_create.php", {
      start: args.start,
      end: args.end,
      location: locationId,
      person: args.resource
    });

    const id = data.id;

    scheduler.events.add({
      start: args.start,
      end: args.end,
      id: id,
      resource: args.resource,
      location: locationId,
      person: args.resource,
      join: id
    });

    scheduler.events.add({
      start: args.start,
      end: args.end,
      id: "L" + id,
      resource: "L" + locationId,
      location: locationId,
      person: args.resource,
      type: "location",
      join: id
    });

    scheduler.links.add({
      id:  "L" + id,
      from: "L" + id,
      to: id,
      type: "FinishToFinish",
      layer: "Above",
      color: "#e69138"
    });
  },
  // ...
};

Each assignment is represented by two Scheduler events (one in the location row and one in the person row) and a link between them which connects them visually:

New Assignment in React Shift Planning Application

Delete the Shift Assignment

Delete a Shift Assignment in React Shift Scheduling Application

In this step, we will add a custom delete icon to the location assignment. That will let users delete the shift assignment and free the slot.

The delete icon can be added by creating an event active area in the onBeforeEventRender event handler using the args.data.areas property.

const config = {
  // ...
  onBeforeEventRender: args => {

    const isLocation = args.data.type === "location";
    const inactive = args.data.type === "inactive";

    if (isLocation) {
      const person = scheduler.rows.find(args.data.person);

      // args.data.backColor = "#bfd9a9";
      args.data.fontColor = "#fff";
      args.data.backColor = "#3d85c6";
      args.data.borderColor = "darker";
      args.data.barHidden = true;
      args.data.text = person.name;
      args.data.moveVDisabled = true;

      args.data.areas = [
        {
          right: 5,
          top: 8,
          height: 22,
          width: 22,
          cssClass: "scheduler_default_event_delete",
          style: "background-color: #fff; border: 1px solid #ccc; box-sizing: border-box; border-radius: 10px; padding: 0px; border: 1px solid #999",
          visibility: "Visible",
          onClick: async args => {

            const modal = await DayPilot.Modal.confirm("Delete this assignment?");

            if (modal.canceled) {
              return;
            }
            const locationAssignment = args.source;

            const locationAssignmentId = locationAssignment.data.id;
            const personAssignmentId = locationAssignment.data.join;

            await DayPilot.Http.post("/api/shift_delete.php", {id: personAssignmentId});

            scheduler.events.remove(locationAssignmentId);
            scheduler.events.remove(personAssignmentId);
            scheduler.links.remove(locationAssignmentId);
          }
        }
      ];
    } else {
      const location = locationsRef.current.find(item => item.id === args.data.location);

      if (location) {
        args.data.text = location.name;
      }

      if (inactive) {
        args.data.backColor = "#eee";
        args.data.fontColor = "#666";
        args.data.barHidden = true;
        args.data.moveDisabled = true;
        args.data.resizeDisabled = true;
      } else {
        args.data.fontColor = "#fff";
        args.data.backColor = "#6fa8dc";
        args.data.borderColor = "darker";
        args.data.barHidden = true;
      }
    }

  },
  // ...
};

The onClick handler of the active area calls the backend using /api/shift_delete.php and removes the corresponding events.

Change the Shift Assignment using Drag and Drop

It’s possible to change the assigned person by dragging the employee assignment vertically:

Change the Shift Assignment using Drag and Drop in React Shift Scheduling App

It’s also possible to change the shift slot by dragging the assignment horizontally in the location row. The employee assignment remains the same in this case:

Change the Shift Time Slot in the React Shift Scheduling App

These drag and drop modifications are implemented using onEventMove event handler.

When creating events representing the shift assignment, we have linked them together using join property of the event data object. Events with the same join value will be moved together during drag and drop.

scheduler.events.add({
  start: args.start,
  end: args.end,
  id: id,
  resource: args.resource,
  location: locationId,
  person: args.resource,
  join: id
});

scheduler.events.add({
  start: args.start,
  end: args.end,
  id: "L" + id,
  resource: "L" + locationId,
  location: locationId,
  person: args.resource,
  type: "location",
  join: id
});

Events in the location row will only move vertically (this will let us change the time slot):

onBeforeEventRender: args => {

  // ...

  args.data.moveVDisabled = true;

  // ...
}

And events in the employee row will only move horizontally (this will let us change the employee).

onBeforeEventRender: args => {

  // ...

  args.data.moveHDisabled = true;

  // ...
}

When the event is dropped at the target position, the Scheduler fires the onEventMove event handler. We will use it to update the database record.

const config = {
  // ...
  onEventMove: async args => {
    const e = args.e;
    if (e.data.type === "location") {
      const {data} = await DayPilot.Http.post("/api/shift_update_time.php", {
        id: e.data.join,
        start: args.newStart,
        end: args.newEnd
      });

      scheduler.message(data.message);
    } else {
      const {data} = await DayPilot.Http.post("/api/shift_update_person.php", {
        id: e.data.join,
        person: args.newResource
      });

      scheduler.message(data.message);

      const locationAssignment = scheduler.events.find("L" + e.data.join);
      locationAssignment.data.person = args.newResource;
      scheduler.events.update(locationAssignment);
    }
  },
  // ...
};

PHP Backend

The React application uses http-proxy-middleware to proxy the /api/* calls to the PHP backend application running at http://172.0.0.1:8090 (note that the proxy may not work properly if you start the PHP web server at localhost:8090 instead of 127.0.0.1:8090).

You can start the API backend (react-shift-scheduling-php project) using the PHP built-in web server.

Linux:

php -S 127.0.0.1:8090 -t /path/to/react-shift-scheduling-php

Windows:

C:\php\php.exe -S 127.0.0.1:8090 -t C:\path\to\react-shift-scheduling-php

MySQL Database Schema

By default, the PHP backend uses an SQLite database which will be created and initialized automatically in the local directory.

You can switch to MySQL by editing _db.php file. Make sure that you adjust the database connection parameters in _db_mysql.php (server name, username, password.). The database will be created and initialized automatically if it doesn’t exist.

This is the MySQL database schema that will be used for the new database:

CREATE TABLE person (
  id INTEGER PRIMARY KEY,
  name VARCHAR(200)
);

CREATE TABLE location (
  id INTEGER PRIMARY KEY,
  name VARCHAR(200)
);

CREATE TABLE assignment (
  id INTEGER PRIMARY KEY AUTO_INCREMENT,
  person_id INTEGER,
  location_id INTEGER,
  assignment_start DATETIME,
  assignment_end DATETIME
);

Full Source Code

Here is the full source code of the React frontend component that displays the shift schedule:

import React, {useEffect, useRef, useState} from 'react';
import {DayPilot, DayPilotScheduler} from "daypilot-pro-react";
import Locations from "./Locations";

const Scheduler = () => {

  const [selectedLocation, setSelectedLocation] = useState(null);
  const [locations, setLocations] = useState([]);
  const [timeline, setTimeline] = useState([]);
  const [scheduler, setScheduler] = useState(null);

  const selectedLocationRef = useRef();
  selectedLocationRef.current = selectedLocation;

  const locationsRef = useRef();
  locationsRef.current = locations;

  const createTimeline = () => {
    const days = DayPilot.Date.today().daysInMonth();
    const start = DayPilot.Date.today().firstDayOfMonth();

    const result = [];
    for (let i = 0; i < days; i++) {
      const day = start.addDays(i);
      result.push({
        start: day.addHours(0),
        end: day.addHours(8)
      });
      result.push({
        start: day.addHours(8),
        end: day.addHours(16)
      });
      result.push({
        start: day.addHours(16),
        end: day.addHours(24)
      });
    }
    return result;

  }

  const config = {

    timeHeaders: [{groupBy: "Month"}, {groupBy: "Day", format: "dddd M/d/yyyy"}, {groupBy: "Cell"}],
    businessBeginsHour: 0,
    businessEndsHour: 24,
    businessWeekends: true,
    scale: "Manual",
    cellWidth: 130,
    eventHeight: 40,
    headerHeight: 30,
    treeEnabled: true,
    allowEventOverlap: false,
    multiMoveVerticalMode: "Master",
    eventResizeHandling: "Disabled",
    rowHeaderColumns: [
      {name: "Name", display: "name"},
      {name: "Total"}
    ],
    linkBottomMargin: 20,
    linkShape: "RightAngled",
    linkWidth: 2,
    onBeforeTimeHeaderRender: args => {
      if (args.header.level === 2) {
        args.header.text = args.header.start.toString("h") + args.header.start.toString("tt").substring(0, 1).toLowerCase();
      }
    },
    onTimeRangeSelected: async args => {

      const row = scheduler.rows.find(args.resource);
      if (row.index === 0) {
        await DayPilot.Modal.alert("No assignment for this shift.<br><br>Click below to create a new assignment.");
        scheduler.clearSelection();
        return;
      }

      const modal = await DayPilot.Modal.confirm("Create a new assignment?");
      scheduler.clearSelection();
      if (modal.canceled) {
        return;
      }
      const locationId = selectedLocationRef.current.id;

      const {data} = await DayPilot.Http.post("/api/shift_create.php", {
        start: args.start,
        end: args.end,
        location: locationId,
        person: args.resource
      });

      const id = data.id;

      scheduler.events.add({
        start: args.start,
        end: args.end,
        id: id,
        resource: args.resource,
        location: locationId,
        person: args.resource,
        join: id
      });

      scheduler.events.add({
        start: args.start,
        end: args.end,
        id: "L" + id,
        resource: "L" + locationId,
        location: locationId,
        person: args.resource,
        type: "location",
        join: id
      });

      scheduler.links.add({
        id:  "L" + id,
        from: "L" + id,
        to: id,
        type: "FinishToFinish",
        layer: "Above",
        color: "#e69138"
      });
    },
    onBeforeCellRender: args => {
      if (args.cell.y === 0) {
        args.cell.backColor = "#fff2cc";
      }
    },
    onBeforeRowHeaderRender: args => {
      const duration = args.row.events.totalDuration();
      const columnTotal = args.row.columns[1];
      if (duration.totalHours() > 0 && columnTotal) {
        columnTotal.text = duration.totalHours() + "h";
      }
      if (args.row.data.type === "location") {
        args.row.backColor = "#ffe599";
        args.row.backColor = "#ffdb5f";
        args.row.fontColor = "#000";
        if (columnTotal) {
          columnTotal.fontColor = "#000";
        }
      }
    },
    onEventMove: async args => {
      const e = args.e;
      if (e.data.type === "location") {
        const {data} = await DayPilot.Http.post("/api/shift_update_time.php", {
          id: e.data.join,
          start: args.newStart,
          end: args.newEnd
        });

        scheduler.message(data.message);
      } else {
        const {data} = await DayPilot.Http.post("/api/shift_update_person.php", {
          id: e.data.join,
          person: args.newResource
        });

        scheduler.message(data.message);

        const locationAssignment = scheduler.events.find("L" + e.data.join);
        locationAssignment.data.person = args.newResource;
        scheduler.events.update(locationAssignment);
      }
    },
    onTimeRangeSelecting: args => {
      if (args.duration.totalHours() > 8) {
        args.allowed = false;
        args.right.enabled = true;
        args.right.text = "Max duration is 8 hours";
      }
    },
    onBeforeGridLineRender: args => {
      const isLocation = args.row && typeof args.row.id === "string" && args.row.id.startsWith("L");
      if (isLocation) {
        args.color = "#aaa";
      }
    },
    onBeforeEventRender: args => {

      const isLocation = args.data.type === "location";
      const inactive = args.data.type === "inactive";

      if (isLocation) {
        const person = scheduler.rows.find(args.data.person);

        // args.data.backColor = "#bfd9a9";
        args.data.fontColor = "#fff";
        args.data.backColor = "#3d85c6";
        args.data.borderColor = "darker";
        args.data.barHidden = true;
        args.data.text = person.name;
        args.data.moveVDisabled = true;

        args.data.areas = [
          {
            right: 5,
            top: 8,
            height: 22,
            width: 22,
            cssClass: "scheduler_default_event_delete",
            style: "background-color: #fff; border: 1px solid #ccc; box-sizing: border-box; border-radius: 10px; padding: 0px; border: 1px solid #999",
            visibility: "Visible",
            onClick: async args => {

              const modal = await DayPilot.Modal.confirm("Delete this assignment?");

              if (modal.canceled) {
                return;
              }
              const locationAssignment = args.source;

              const locationAssignmentId = locationAssignment.data.id;
              const personAssignmentId = locationAssignment.data.join;

              await DayPilot.Http.post("/api/shift_delete.php", {id: personAssignmentId});

              scheduler.events.remove(locationAssignmentId);
              scheduler.events.remove(personAssignmentId);
              scheduler.links.remove(locationAssignmentId);
            }
          }
        ];
      } else {
        const location = locationsRef.current.find(item => item.id === args.data.location);

        if (location) {
          args.data.text = location.name;
        }

        if (inactive) {
          args.data.backColor = "#eee";
          args.data.fontColor = "#666";
          args.data.barHidden = true;
          args.data.moveDisabled = true;
          args.data.resizeDisabled = true;
        } else {
          args.data.fontColor = "#fff";
          args.data.backColor = "#6fa8dc";
          args.data.borderColor = "darker";
          args.data.barHidden = true;
        }
      }

    },
    onEventMoving: args => {
      args.links = [];
    },
  };

  const loadData = async (location) => {
    if (!location) {
      return;
    }

    const start = scheduler.visibleStart();
    const end = scheduler.visibleEnd();
    const promisePeople = DayPilot.Http.get(`/api/shift_people.php`);
    const promiseAssignments = DayPilot.Http.get(`/api/shift_assignments.php?location=${location.id}&start=${start}&end=${end}`);
    const [{data: people}, {data: assignments}] = await Promise.all([promisePeople, promiseAssignments]);

    const rows = [
      {id: "L" + location.id, name: location.name, type: "location"},
      ...people
    ];

    const links = [];
    assignments.filter(e => e.type === "location").forEach(e => {
      links.push({
        id: e.id,
        from: e.id,
        to: e.join,
        type: "FinishToFinish",
        layer: "Above",
        color: "#e69138"
      });
    });

    scheduler.update({
      resources: rows,
      events: assignments,
      links
    });
  };

  const loadLocations = async () => {
    const { data } = await DayPilot.Http.get("/api/shift_locations.php");
    setLocations(data);
    // Only set selectedLocation if it's not already set
    if (!selectedLocation && data.length > 0) {
      setSelectedLocation(data[0]);
    }
  };

  // initialize timeline
  useEffect(() => {
    setTimeline(createTimeline());
    loadLocations();
  }, []);

  useEffect(() => {
    if (scheduler && selectedLocation) {
      loadData(selectedLocation);
    }
  }, [selectedLocation, scheduler]);

  return (
    <div>
      <Locations
        onChange={setSelectedLocation}
        data={locations}
        selectedValue={selectedLocation}
      />
      <DayPilotScheduler
        {...config}
        timeline={timeline}
        controlRef={setScheduler}
      />
    </div>
  );
}

export default Scheduler;

You can download the complete React project using the link at the top of the tutorial.

History

  • October 9, 2024: Upgraded to DayPilot Pro 2024.4.6190; PHP 8.3 compatibility; moving shifts in time fixed; styling updates.

  • July 18, 2023: Upgraded to DayPilot Pro 2023.2.5582; upgraded to React 18, Hooks API.

  • February 18, 2021: Initial release.