Features
PHP shift planning web application tutorial
The application displays a shift plan using a visual JavaScript Scheduler component from DayPilot Pro for JavaScript
You can plan shifts for multiple locations/positions
Each day is split into three 8-hour shifts
See all assignments for a selected location
You can use drag and drop to move assignments between shifts and employees
PHP and MySQL used for the backend REST API
See also the 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.
Shift Planning: How It Works
This PHP shift scheduling system lets you plan shift coverage for multiple locations. You can switch between locations using a drop down menu. Each view displays shifts for the selected locations, together with details of the assignment. All available people are displayed as additional rows - this provides an overview of assignments and availability for each of the employees.
To create a new shift assignment, select the target location using the drop-down list and click the selected cell grid.
A new shift assignment will be created for the selected person and location.
Each shift assignment will be displayed in two rows - in the location row (at the top) and in the row of the selected person.
The Scheduler will display a total for each row (location and people) in a special "Total" columns:
The data for other locations as displayed as well. If you switch to a different location the existing shift assignments will be displayed in gray color:
You can easily change the assigned person by dragging the shift assignment vertically to another row:
You can also move the person assignment to another shift by dragging the assignment horizontally in the location row:
Shift Planner: Building the PHP Application
The PHP application consists of a frontend part (JavaScript/HTML5) and a backend part (PHP/JSON).
The frontend of the application can be found in index.html file. It's implemented using HTML5 and JavaScript and it uses a Scheduler component to display the shift scheduler. The scheduler communicates with the backend using HTTP calls.
The backend consists of several PHP files that provide database access using simple JSON endpoints:
backend_assignments.php
backend_create.php
backend_delete.php
backend_locations.php
backend_people.php
backend_update_person.php
backend_update_time.php
Shift Scheduler Configuration
The frontend of the shift planning application is built using JavaScript Scheduler from DayPilot Pro for JavaScript package. For an introduction to using the Scheduler component with PHP please see the basic tutorial:
HTML5 Scheduler Tutorial (PHP)
The first version of the application frontend was generated using Scheduler UI Builder - an online configurator that lets you customize the Scheduler and generate a zip file with all dependencies.
The Scheduler configuration source code has the following structure:
<!-- DayPilot Pro library -->
<script src="js/daypilot/daypilot-all.min.js"></script>
<!-- Scheduler placeholder -->
<div id="dp"></div>
<!-- Scheduler initialization -->
<script>
const dp = new DayPilot.Scheduler("dp", {
cellWidth: 40,
// other configuration options
});
dp.init();
</script>
Shift Planner Timeline
Our shift planning application will display three 8-hour shifts per day. The Scheduler component supports several standard scale units (such as hour, day, week, month). There is no special option for 8-hour segments, but we can switch the scheduler to manual mode and generate a completely customized timeline:
const dp = new DayPilot.Scheduler("dp", {
scale: "Manual",
timeline: getTimeline(),
// ...
});
dp.init();
function getTimeline() {
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 custom timeline will start on the first day of the current month. Each day will be split into three 8-hour cells.
We will also set the time header to display the corresponding month, day, and starting hour in three levels:
const dp = new DayPilot.Scheduler("dp", {
scale: "Manual",
timeline: getTimeline(),
timeHeaders: [{groupBy: "Month"}, {groupBy: "Day"}, {groupBy: "Cell"}],
onBeforeTimeHeaderRender: (args) => {
if (args.header.level === 2) {
args.header.html = args.header.start.toString("h") + args.header.start.toString("tt").substring(0, 1).toLowerCase();
}
},
// ...
});
Displaying People as Rows
We will use rows.load() method of the scheduler component to load the row data from MySQL database:
dp.rows.load("backend_people.php");
This call invokes an HTTP GET request to backend_people.php script. This script loads the employee data from a database and returns a JSON array:
[
{"id":1,"name":"Adam"},
{"id":2,"name":"Cheryl"},
{"id":5,"name":"Eliah"},
{"id":3,"name":"Emily"},
{"id":4,"name":"Eva"},
{"id":6,"name":"John"},
{"id":7,"name":"Sally"}
]
The structure of the array items is described in the documentation of DayPilot.Scheduler.resources property. We will only use the minimum required properties (id and name).
backend_people.php
<?php
require_once '_db.php';
$stmt = $db->prepare("SELECT * FROM person ORDER BY name");
$stmt->execute();
$people = $stmt->fetchAll();
class Item {
public $id;
public $name;
}
$result = array();
foreach($people as $person) {
$r = new Item();
$r->id = $person['id'];
$r->name = $person['name'];
$result[] = $r;
}
header('Content-Type: application/json');
echo json_encode($result);
The layout of our shift planning system requires that there is one more row above the rows with people - that row will display the selected location. We will use the success callback of rows.load() to modify the result and add one more row at the top of the shift scheduler:
dp.rows.load("backend_people.php", (args) => {
args.data.splice(0, 0, {id: "L" + item.id, name: item.name, type: "location"});
});
The scheduler requires that each row has a unique ID. It's also necessary for clear identification of each row. The locations are stored in a different database table and the location IDs can overlap with people IDs. That's why we will prefix the row ID with "L" string to indicate that this row hold location data.
We want to give users a visual hint that the first row has a special meaning. We will use onBeforeRowHeaderRender event handler to set a custom color of the first row header:
onBeforeRowHeaderRender: (args) => {
const duration = args.row.events.totalDuration();
if (duration.totalHours() > 0) {
args.row.columns[1].html = duration.totalHours() + "h";
}
if (args.row.data.type === "location") {
args.row.backColor = "#e06146";
args.row.fontColor = "#fff";
args.row.columns[1].fontColor = "#fff";
}
},
To customize the scheduler grid cells, we will use onBeforeCellRender event handler:
onBeforeCellRender: (args) => {
if (args.cell.y === 0) {
args.cell.backColor = "#e0b4a8";
}
},
Loading Shift Assignments
The Scheduler allows loading the event data using events.load() method.
dp.events.load("backend_assignments.php?location=" + locationId);
The backend_assignments.php script loads the shift assignments from MySQL database. The assignments for the active location (which is passed to the PHP script using location query string parameter) are returned as two items:
the first one will be displayed in the location row (displayed at the top of the Scheduler)
the second one will be displayed in the matching person row
Sample JSON response:
[
{
"id":"L3",
"text":"",
"start":"2024-07-01T08:00:00",
"end":"2024-07-01T16:00:00",
"resource":"L1",
"join":3,
"person":1,
"location":1,
"type":"location"
},
{
"id":3,
"text":"",
"start":"2024-07-01T08:00:00",
"end":"2024-07-01T16:00:00",
"resource":1,
"join":3,
"person":1,
"location":1
}
]
The event items contain the required fields (id, text, start, end, resource, join - see also DayPilot.Event.data for the event structure description) and also a couple of custom fields that will help us when working with the items on the client side (person, location, type).
backend_assigments.php
<?php
require_once '_db.php';
$stmt = $db->prepare("SELECT * FROM assignment WHERE NOT ((assignment_end <= :start) OR (assignment_start >= :end))");
$stmt->bindParam(':start', $_GET['start']);
$stmt->bindParam(':end', $_GET['end']);
$stmt->execute();
$result = $stmt->fetchAll();
class Event {
public $id;
public $text;
public $start;
public $end;
public $resource;
public $person;
public $location;
public $type;
public $join;
}
$events = array();
$location = $_GET['location'];
foreach($result as $row) {
$active = $row['location_id'] == $location;
if ($active) {
$e = new Event();
$e->id = $row['id'];
$e->text = "";
$e->start = $row['assignment_start'];
$e->end = $row['assignment_end'];
$e->resource = $row['person_id'];
$e->person = $row['person_id'];
$e->location = $row['location_id'];
$e->join = $row['id'];
$events[] = $e;
$e = new Event();
$e->id = "L".$row['id'];
$e->text = "";
$e->start = $row['assignment_start'];
$e->end = $row['assignment_end'];
$e->resource = 'L'.$row['location_id'];
$e->person = $row['person_id'];
$e->location = $row['location_id'];
$e->type = 'location';
$e->join = $row['id'];
$events[] = $e;
}
else {
$e = new Event();
$e->id = $row['id'];
$e->text = "";
$e->start = $row['assignment_start'];
$e->end = $row['assignment_end'];
$e->resource = $row['person_id'];
$e->person = $row['person_id'];
$e->location = $row['location_id'];
$e->type = 'inactive';
$e->join = $row['id'];
$events[] = $e;
}
}
header('Content-Type: application/json');
echo json_encode($events);
For the sake of simplicity, we use the built-in helper methods (rows.load() and events.load()) to load the data using two separate HTTP requests which cause two Scheduler updates. It is also possible to run two parallel HTTP requests, wait for both of them to complete and update the Scheduler just once.
Moving Shift Assignments using Drag and Drop
Each assignment is displayed as two boxes in the shift planning view - once in the location row (the first one) and once in the person row. The Scheduler offers several UI helpers to handle such complex assignments. We will use these features to customize the drag and drop behavior:
These two events are marked as joint events (both have the same join property value). This automatically enables event multi-moving - both events will be moved if you start dragging one of them. That helps when the users wants to moving the assignment in time. In this case, both boxes will be moved automatically and provide visual feedback to the user.
In the vertical direction (resources axis), we will only move the dragged item (not both of them) by setting multiMoveVerticalMode property to "Master". This will ensure that the box in the first ("Location") row will stay unmodified when we change the assigned person.
We will restrict the direction in which the event box can be moved. The event boxes in the first ("Location") row can be moved horizontally (to change the shift). The event boxes in the people rows can be only moved vertically (to change the person assignment). This behavior can be set in onBeforeEventRender event handler by setting moveVDisabled and moveHDisabled properties:
onBeforeEventRender: (args) => {
const isLocation = args.data.type === "location";
if (isLocation) {
// ...
args.data.moveVDisabled = true;
}
else {
// ...
args.data.moveHDisabled = true;
}
}
Changing the shift (time):
Changing the assignment (person):
As soon as the user drops the event at the target location the Scheduler fires onEventMove event handler. We will use it to save the changes in the database:
onEventMove: async (args) => {
const e = args.e;
if (e.data.type === "location") {
const params = {
id: e.data.join,
start: args.newStart,
end: args.newEnd
};
const {data} = await DayPilot.Http.post("backend_update_time.php", params);
dp.message(data.message);
} else {
const params = {
id: e.data.join,
person: args.newResource
};
const {data} = await DayPilot.Http.post("backend_update_person.php", params);
dp.message(data.message);
const locationAssignment = dp.events.find("L" + e.data.join);
locationAssignment.data.person = args.newResource;
dp.events.update(locationAssignment);
}
},
First, we detect which event type has been dragged.
For the location row, we call backend_update_time.php endpoint to change the assignment time.
For the people rows, we call backend_update_person.php endpoint to change the assignment person.
On the server side, we simply update the shift assignment record in the database:
backend_update_time.php
<?php
require_once '_db.php';
$json = file_get_contents('php://input');
$params = json_decode($json);
$stmt = $db->prepare("UPDATE assignment SET assignment_start = :start, assignment_end = :end WHERE id = :id");
$stmt->bindParam(':start', $params->start);
$stmt->bindParam(':end', $params->end);
$stmt->bindParam(':id', $params->id);
$stmt->execute();
class Result {
public $result;
public $message;
public $id;
}
$response = new Result();
$response->result = 'OK';
$response->message = 'Updated, id: '.$params->id;
$response->id = $db->lastInsertId();
header('Content-Type: application/json');
echo json_encode($response);
backend_update_person.php
<?php
require_once '_db.php';
$json = file_get_contents('php://input');
$params = json_decode($json);
$stmt = $db->prepare("UPDATE assignment SET person_id = :person WHERE id = :id");
$stmt->bindParam(':person', $params->person);
$stmt->bindParam(':id', $params->id);
$stmt->execute();
class Result {
public $result;
public $message;
public $id;
}
$response = new Result();
$response->result = 'OK';
$response->message = 'Updated, id: '.$params->id;
$response->id = $db->lastInsertId();
header('Content-Type: application/json');
echo json_encode($response);
Switching Shift Locations Using a Dropdown List
Our shift planning application lets users switch the current location using a drop-down list that is displayed above the main scheduling grid.
The HTML is very simple:
Location: <select id="locations"></select>
The drop-down list items need to be loaded from a database using a PHP API endpoint.
Also, we need the shift schedule to automatically update when the current location is changed.
To manage the location list initialization and event handling, we include the following code, enclosed under a locations
property.
The locations
object defines the following methods and properties to manage a list of locations for our a shift scheduling application.
list
: an array that holds the location data.find(id)
: a method to find a location from the list by its id.element
: references the DOM element with the id 'locations'.activate(location)
: a method to set a location as active. It updates the shift schedule (using the DayPilot library) based on the activated location.load()
: an asynchronous method that retrieves location data from a backend PHP API endpoint, populates thelist
array, and updates the dropdown options in the HTML document.init()
: a method to initialize event listeners for the dropdown menu. It triggers theload()
method to fetch the data when the DOM content is loaded, and updates the active location when a new option is selected in the dropdown.
const locations = {
list: [],
find(id) {
if (!locations.list) {
return null;
}
return locations.list.find(item => item.id === id);
},
element: document.querySelector("#locations"),
activate(location) {
let item = location;
if (typeof location !== "object") {
item = locations.find(location);
}
dp.events.list = [];
dp.rows.load("backend_people.php", (args) => {
args.data.splice(0, 0, {id: "L" + item.id, name: item.name, type: "location"});
});
dp.events.load("backend_assignments.php?location=" + item.id);
},
async load() {
const {data} = await DayPilot.Http.get("backend_locations.php");
locations.list = data;
locations.element.innerHTML = '';
locations.list.forEach((item) => {
const option = document.createElement("option");
option.value = item.id;
option.innerText = item.name;
locations.element.appendChild(option);
});
locations.activate(locations.list[0]);
},
init() {
window.addEventListener("DOMContentLoaded", () => {
locations.element.addEventListener("change", (ev) => {
const locationId = parseInt(ev.currentTarget.value);
locations.activate(locationId);
});
locations.load();
});
}
};
The list of locations available for shift scheduling is stored in a MySQL database, in the location
table. The following PHP script (backend_locations.php
) returns a list of locations in JSON format:
<?php
require_once '_db.php';
$stmt = $db->prepare("SELECT * FROM location ORDER BY name");
$stmt->execute();
$locations = $stmt->fetchAll();
class Item {
public $id;
public $name;
}
$result = array();
foreach($locations as $location) {
$r = new Item();
$r->id = $location['id'];
$r->name = $location['name'];
$result[] = $r;
}
header('Content-Type: application/json');
echo json_encode($result);
As you can see, handling the location-switching logic using vanilla JavaScript requires some boilerplate. There is a React version of the shift scheduling tutorial available so you can compare the logic with the React version and see how React makes it easier.
Database Storage
By default, the shift planning application uses file-based SQLite database. It is created automatically (daypilot.sqlite
file) in the application directory on startup (make sure that the application has write permissions). This way you can test the application without any additional database configuration.
You can switch to MySQL backend easily by changing the _db.php file as follows:
<?php
// use sqlite
// require_once '_db_sqlite.php';
// use MySQL
require_once '_db_mysql.php';
Make sure that _db_mysql.php uses the correct server address/port, username and password:
<?php
$host = "127.0.0.1";
$port = 3306;
$username = "username";
$password = "password";
$database = "shift-planning";
The database (shift-planning
by default) will be created and initialized automatically if it doesn't exist.
MySQL Database Schema
Structure of person table:
CREATE TABLE `person` (
`id` INT(11) NOT NULL,
`name` VARCHAR(200) NULL DEFAULT NULL,
PRIMARY KEY (`id`)
);
Structure of location table:
CREATE TABLE `location` (
`id` INT(11) NOT NULL,
`name` VARCHAR(200) NULL DEFAULT NULL,
PRIMARY KEY (`id`)
);
Structure of assignment table:
CREATE TABLE `assignment` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`person_id` INT(11) NULL DEFAULT NULL,
`location_id` INT(11) NULL DEFAULT NULL,
`assignment_start` DATETIME NULL DEFAULT NULL,
`assignment_end` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`id`)
);
History
May 21, 2024: DayPilot Pro version upgraded to 2024.2.5912. Moving of related events fixed (horizontal direction). PHP 8+ compatibility (dynamic class members removed, DB types). Location switching fixed.
May 14, 2023: DayPilot Pro version upgraded to 2023.2.5574. Using ES6+ syntax. Switched to axios-style DayPilot.Http.get() and DayPilot.Http.post().
May 11, 2021: DayPilot Pro version upgraded to 2021.2.4972.
November 6, 2020: DayPilot Pro version upgraded to 2020.4.4736. Updating text of the event in the location row when changing the person assignment. jQuery removed.
May 21, 2020 - DayPilot Pro version upgraded to 2020.2.4470. Using tabular mode for row header columns.
September 10, 2019 - DayPilot Pro version upgraded to 2019.3.3999. Assignment moving implemented.
July 6, 2018 - Initial release