Features
PHP annual leave scheduling and tracking system that displays records in a visual grid.
The app allows to schedule annual leave for each employee in half-day units.
The rows display the total annual leave days for every person.
The records highlight the status using color coding and status icons (pending, approved).
You can create and update the annual leave requests using drag and drop.
This app uses the HTML5/JavaScript Scheduler component from DayPilot Pro for JavaScript (trial version).
See also the Angular 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. Buy a license.
Annual Leave App: Scheduler UI Component
The annual leave schedule will be displayed using DayPilot JavaScript Scheduler. This is an advanced scheduling UI component that lets you create different types of planning, management, and reservation applications.
For an introduction to using the Scheduler, please see HTML5 Scheduler tutorial. It explains the basic concepts, installation and configuration steps.
In this tutorial, we will use the following Scheduler configuration:
1. The scheduler will display the current year:
{
startDate: DayPilot.Date.today().firstDayOfYear(),
days: DayPilot.Date.today().daysInYear()
}
2. The grid cell size (see scale) will be set to half a day (720 minutes):
{
scale: "CellDuration",
cellDuration: 720,
}
3. The time headers will be displayed in two rows, grouped by by month and day:
{
timeHeaders: [
{groupBy: "Month"},
{groupBy: "Day", format: "d"}
],
}
4. The initial scrollbar position will be set to today:
{
scrollTo: DayPilot.Date.today(),
}
5. We will load the employee data (to be displayed as rows) using DayPilot.Scheduler.rows.load() method:
scheduler.rows.load("backend_employees.php");
The complete Scheduler configuration:
<script src="js/daypilot/daypilot-all.min.js"></script>
<div class="main">
<div id="scheduler"></div>
</div>
<script>
const scheduler = new DayPilot.Scheduler("scheduler", {
timeHeaders: [
{groupBy: "Month"},
{groupBy: "Day", format: "d"}
],
scale: "CellDuration",
cellDuration: 720,
days: DayPilot.Date.today().daysInYear(),
startDate: DayPilot.Date.today().firstDayOfYear(),
eventHeight: 40,
headerHeight: 40,
cellWidth: 20,
scrollTo: DayPilot.Date.today()
});
scheduler.init();
scheduler.rows.load("backend_employees.php");
</script>
The backend_employees.php
script returns a list of employees in JSON format:
[
{"id":"1","name":"Emerson, Adam"},
{"id":"2","name":"Irwin, Cheryl"},
{"id":"3","name":"Jameson, Emily"},
{"id":"5","name":"Kingston, Eliah"},
{"id":"6","name":"Niles, Taylor"},
{"id":"4","name":"Rodriguez, Eva"},
{"id":"7","name":"Thomas, Jo"}
]
The structure of the row/resource items is described in DayPilot.Scheduler.resources property docs.
The source code of backend_employees.php
:
<?php
require_once '_db.php';
$stmt = $db->prepare("SELECT * FROM person ORDER BY last, first");
$stmt->execute();
$list = $stmt->fetchAll();
class Employee {}
$result = array();
foreach($list as $employee) {
$r = new Employee();
$r->id = $employee['id'];
$r->name = $employee['last'].', '.$employee['first'];
$result[] = $r;
}
header('Content-Type: application/json');
echo json_encode($result);
Day Separators
In order to highlight start and end of each day, we will add separators to the Scheduler. The separators will be located at the start of each day and they will be displayed as a gray (#ddd
) vertical line:
<div class="main">
<div id="scheduler"></div>
</div>
<script>
const scheduler = new DayPilot.Scheduler("scheduler", {
timeHeaders: [{groupBy: "Month"}, {groupBy: "Day", format: "d"}],
// ...
});
scheduler.init();
const app = {
createSeparators() {
const separators = [];
for (let i = 0; i < scheduler.days; i++) {
separators.push({location: scheduler.startDate.addDays(i), color: "#ddd"});
}
return separators;
},
async loadData() {
const {data: resources} = await DayPilot.Http.get("backend_employees.php");
const separators = this.createSeparators();
scheduler.update({resources, events, separators});
}
};
app.loadData();
</script>
Loading Annual Leave Data
The Scheduler provides a shortcut method for loading the event data from a remote location - DayPilot.Scheduler.events.load():
scheduler.events.load("backend_events.php");
Sample JSON response:
[
{"start":"2025-06-22T00:00:00","end":"2025-06-26T00:00:00","resource":"2","id":"30"},
{"start":"2025-06-23T12:00:00","end":"2025-06-26T00:00:00","resource":"3","id":"31"},
{"start":"2025-06-21T00:00:00","end":"2025-06-23T12:00:00","resource":"6","id":"32"},
{"start":"2025-06-28T00:00:00","end":"2025-07-01T00:00:00","resource":"4","id":"34"}
]
The structure of the event items is described at DayPilot.Event.data property docs. Note that the value of the resource
property matches the row id.
And this is the source code of backend_events.php
:
<?php
require_once '_db.php';
$stmt = $db->prepare("SELECT * FROM leave_event WHERE NOT ((leave_end <= :start) OR (leave_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 $approved;
}
$events = array();
foreach($result as $row) {
$e = new Event();
$e->id = (int) $row['id'];
$e->text = "";
$e->start = $row['leave_start'];
$e->end = $row['leave_end'];
$e->resource = (int) $row['person_id'];
$e->approved = (bool) $row['approved'];
$events[] = $e;
}
header('Content-Type: application/json');
echo json_encode($events);
Next, we will use the onBeforeEventRender event to customize the event appearance.
Depending on the record status (approved/pending), we will use green or orange color for the record background and duration bar.
For approved requests, we will display a round icon with a checkmark. Otherqise, a “pending” icon and text will be displayed.
On the right side, there will be a “three dots” icons that opens a context menu on click.
<script>
const scheduler = new DayPilot.Scheduler("scheduler", {
onBeforeEventRender: (args) => {
const duration = new DayPilot.Duration(args.data.start, args.data.end);
args.data.html = "";
const approved = args.data.approved;
const statusText = approved ? "Approved" : "Pending";
const statusIcon = approved ? "icons/daypilot.svg#checkmark-2" : "icons/daypilot.svg#figure";
const statusColor = approved ? "#5cb85c" : "#f0ad4e";
if (approved) {
args.data.backColor = "#d9ead3";
args.data.barColor = "#6aa84f";
args.data.barBackColor = args.data.barColor;
}
else {
// orange
args.data.backColor = "#fcf8e3";
args.data.barColor = "#f0ad4e";
args.data.barBackColor = args.data.barColor;
}
args.data.areas = [
{
top: 10,
left: 6,
text: duration.totalDays() + " days",
},
{
top: 32,
left: 6,
width: 20,
height: 20,
symbol: statusIcon,
borderRadius: "50%",
padding: 2,
backColor: statusColor,
fontColor: "#ffffff"
},
{
top: 34,
left: 32,
text: statusText
},
{
top: 20,
right: 6,
width: 20,
height: 20,
symbol: "icons/daypilot.svg#threedots-v",
borderRadius: "50%",
padding: 1,
backColor: "#333333",
fontColor: "#ffffff",
action: "ContextMenu"
}
];
},
// ...
});
scheduler.init();
</script>
The context menu has two items:
The “Approved” menu item lets you change the approval status. Approved records will display a checkmark next to the text.
The “Delete” menu items lets you remove the record from the schedule.
const scheduler = new DayPilot.Scheduler("scheduler", {
// ...
contextMenu: new DayPilot.Menu({
onShow: args => {
const e = args.source;
console.log("e.data", e.data);
if (e.data.approved) {
args.menu.items[0].symbol = "icons/daypilot.svg#checkmark-2";
}
else {
args.menu.items[0].symbol = "";
}
},
items: [
{
text: "Approved",
onClick: async (args) => {
const e = args.source;
e.data.approved = !e.data.approved;
const data = {
id: e.data.id,
approved: e.data.approved
};
await DayPilot.Http.post("backend_approved.php", data);
scheduler.events.update(e);
}
},
{
text: "-"
},
{
text: "Delete",
onClick: async (args) => {
const e = args.source;
scheduler.events.remove(e);
}
},
]
}),
// ...
});
Adding a New Record
When you select a date range using drag and drop, the Scheduler fires onTimeRangeSelected event handler. We will use this event handler to add a new record to our annual leave planner.
The event handler opens a modal dialog with the following fields:
Start (date)
End (date)
Employee (drop-down list)
The form is created using DayPilot Modal library. You can design your own modal dialog using the Modal Builder online application.
onTimeRangeSelected: async (args) => {
const form = [
{ name: "Start", id: "start", dateFormat: "MMMM d, yyyy h:mm tt"},
{ name: "End", id: "end", dateFormat: "MMMM d, yyyy h:mm tt"},
{ name: "Employee", id: "resource", options: scheduler.resources },
];
const data = {
start: args.start,
end: args.end,
resource: args.resource
};
const options = {
autoFocus: false
};
const modal = await DayPilot.Modal.form(form, data, options);
scheduler.clearSelection();
if (modal.canceled) {
return;
}
const {data: result} = await DayPilot.Http.post("backend_create.php", modal.result);
data.id = result.id;
scheduler.events.add(data);
},
When the user confirms the new record details, the application saves the record in the database using an HTTP call to the backend_create.php
script.
backend_create.php
<?php
require_once '_db.php';
$json = file_get_contents('php://input');
$params = json_decode($json);
$stmt = $db->prepare("INSERT INTO leave_event (leave_start, leave_end, person_id) VALUES (:start, :end, :person)");
$stmt->bindParam(':start', $params->start);
$stmt->bindParam(':end', $params->end);
$stmt->bindParam(':person', $params->resource);
$stmt->execute();
class Result {
public $result;
public $message;
public $id;
}
$response = new Result();
$response->result = 'OK';
$response->message = 'Created with id: '.$db->lastInsertId();
$response->id = $db->lastInsertId();
header('Content-Type: application/json');
echo json_encode($response);
Annual Leave Totals
Now we want to display the annual leave totals for each person in the row headers. First, we define the row header columns using rowHeaderColumns property:
<script>
const scheduler = new DayPilot.Scheduler("scheduler", {
rowHeaderColumns: [
{name: "Name", display: "name"},
{name: "Total"}
],
// ...
});
scheduler.init();
</script>
There will be two columns - the first column will display the employee name (it will use the name
property of the resource
array items, as defined using display: "name"
) and the second column will display the total.
We will use the onBeforeRowHeaderRender event to display the total in the second column. The args.row
property (which stores a DayPilot.Row object) provides a helper methods that returns the total duration of all events in a given row: args.row.events.totalDuration()
.
<script>
const scheduler = new DayPilot.Scheduler("scheduler", {
rowHeaderColumns: [
{name: "Name", display: "name"},
{name: "Total"}
],
onBeforeRowHeaderRender: (args) => {
args.row.columns[1].html = "";
const totalDuration = args.row.events.totalDuration();
if (totalDuration.days() > 0) {
args.row.columns[1].html = args.row.events.totalDuration().totalDays() + " days";
}
},
// ...
});
scheduler.init();
</script>
Disabled Weekends
We will mark weekends as disabled using the onBeforeCellRender event. This his will prevent drag-and-drop operation for Saturday and Sunday.
If the user selects a date range that spans a weekend or tries to move an existing annual leave record to Saturday or Sunday, the target position will be displayed in red, and the drop action will be disabled.
<script>
const scheduler = new DayPilot.Scheduler("scheduler", {
// ...
onBeforeCellRender: (args) => {
const day = args.cell.start.getDayOfWeek();
if (day === 6 || day === 0) {
args.cell.disabled = true;
}
},
// ...
});
scheduler.init();
</script>
MySQL Database Schema
Our annual leave application uses the following MySQL database schema. There are two tables, leave_event
and person
.
The person
table stores the employees who will be displayed as rows in the Scheduler:
CREATE TABLE `person` (
`id` INT(11) NOT NULL,
`first` VARCHAR(200) NULL DEFAULT NULL,
`last` VARCHAR(200) NULL DEFAULT NULL,
PRIMARY KEY (`id`)
);
The leave_event
table stores the annual leave records. It identifies the employee (person_id
database column), specifies the start and end date (leave_start
and leave_event
database columns), and keeps the approval status (approved
column):
CREATE TABLE `leave_event` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`person_id` INT(11) NULL DEFAULT NULL,
`leave_start` DATETIME NULL DEFAULT NULL,
`leave_end` DATETIME NULL DEFAULT NULL,
`approved` TINYINT(1) DEFAULT 0,
PRIMARY KEY (`id`)
);
The PHP annual leave application uses SQLite as the default storage. This requires no additional configuration and it will let you start quickly (just make sure that the PHP process has permission to write to the application folder). You can switch the storage to MySQL by editing the _db.php
file.
Full Source Code
Here is the full source code of the frontend part of our PHP annual leave scheduling application (index.html):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>PHP Annual Leave Scheduling (JavaScript/HTML5 Frontend, MySQL Database)</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="main.css" rel="stylesheet" type="text/css"/>
<!-- DayPilot library -->
<script src="js/daypilot/daypilot-all.min.js"></script>
</head>
<body>
<div class="header">
<h1><a href='https://code.daypilot.org/97780/php-annual-leave-scheduling-javascript-html5-frontend-mysql'>PHP Annual Leave Scheduling (JavaScript/HTML5 Frontend, MySQL Database)</a></h1>
<div><a href="https://javascript.daypilot.org/">DayPilot for JavaScript</a> - HTML5 Calendar/Scheduling Components for JavaScript/Angular/React/Vue</div>
</div>
<div class="main">
<div id="scheduler"></div>
</div>
<script>
const scheduler = new DayPilot.Scheduler("scheduler", {
timeHeaders: [
{groupBy: "Month"},
{groupBy: "Day", format: "d"}
],
rowHeaderColumns: [
{name: "Name", display: "name"},
{name: "Total"}
],
scale: "CellDuration",
cellDuration: 720,
days: DayPilot.Date.today().daysInYear(),
startDate: DayPilot.Date.today().firstDayOfYear(),
eventHeight: 60,
headerHeight: 40,
cellWidth: 20,
allowEventOverlap: false,
contextMenu: new DayPilot.Menu({
onShow: args => {
const e = args.source;
console.log("e.data", e.data);
if (e.data.approved) {
args.menu.items[0].symbol = "icons/daypilot.svg#checkmark-2";
}
else {
args.menu.items[0].symbol = "";
}
},
items: [
{
text: "Approved",
onClick: async (args) => {
const e = args.source;
e.data.approved = !e.data.approved;
const data = {
id: e.data.id,
approved: e.data.approved
};
await DayPilot.Http.post("backend_approved.php", data);
scheduler.events.update(e);
}
},
{
text: "-"
},
{
text: "Delete",
onClick: async (args) => {
const e = args.source;
scheduler.events.remove(e);
}
},
]
}),
onBeforeEventRender: (args) => {
args.data.moveVDisabled = true;
const duration = new DayPilot.Duration(args.data.start, args.data.end);
args.data.html = "";
const approved = args.data.approved;
const statusText = approved ? "Approved" : "Pending";
const statusIcon = approved ? "icons/daypilot.svg#checkmark-2" : "icons/daypilot.svg#figure";
const statusColor = approved ? "#5cb85c" : "#f0ad4e";
if (approved) {
// green
args.data.backColor = "#d9ead3";
args.data.barColor = "#6aa84f";
args.data.barBackColor = args.data.barColor;
}
else {
// orange
args.data.backColor = "#fcf8e3";
args.data.barColor = "#f0ad4e";
args.data.barBackColor = args.data.barColor;
}
args.data.areas = [
{
top: 10,
left: 6,
text: duration.totalDays() + " days",
},
{
top: 32,
left: 6,
width: 20,
height: 20,
symbol: statusIcon,
borderRadius: "50%",
padding: 2,
backColor: statusColor,
fontColor: "#ffffff"
},
{
top: 34,
left: 32,
text: statusText
},
{
top: 20,
right: 6,
width: 20,
height: 20,
symbol: "icons/daypilot.svg#threedots-v",
borderRadius: "50%",
padding: 1,
backColor: "#333333",
fontColor: "#ffffff",
action: "ContextMenu"
}
];
},
onBeforeRowHeaderRender: (args) => {
args.row.columns[1].html = "";
const totalDuration = args.row.events.totalDuration();
if (totalDuration.days() > 0) {
args.row.columns[1].html = totalDuration.totalDays() + " days";
}
},
onBeforeCellRender: (args) => {
const day = args.cell.start.getDayOfWeek();
if (day === 6 || day === 0) {
args.cell.disabled = true;
}
},
onTimeRangeSelected: async (args) => {
const form = [
{ name: "Start", id: "start", dateFormat: "MMMM d, yyyy h:mm tt"},
{ name: "End", id: "end", dateFormat: "MMMM d, yyyy h:mm tt"},
{ name: "Employee", id: "resource", options: scheduler.resources },
];
const data = {
start: args.start,
end: args.end,
resource: args.resource
};
const options = {
autoFocus: false
};
const modal = await DayPilot.Modal.form(form, data, options);
scheduler.clearSelection();
if (modal.canceled) {
return;
}
const {data: result} = await DayPilot.Http.post("backend_create.php", modal.result);
data.id = result.id;
scheduler.events.add(data);
},
onEventDeleted: async (args) => {
await DayPilot.Http.post("backend_delete.php", {id: args.e.data.id});
scheduler.message("Deleted");
},
onEventMoved: async (args) => {
const data = {
id: args.e.data.id,
start: args.newStart,
end: args.newEnd,
resource: args.newResource
};
await DayPilot.Http.post("backend_move.php", data);
scheduler.message("Moved");
},
onEventResized: async (args) => {
const data = {
id: args.e.data.id,
start: args.newStart,
end: args.newEnd
};
await DayPilot.Http.post("backend_move.php", data);
scheduler.message("Resized");
},
scrollTo: DayPilot.Date.today()
});
scheduler.init();
const app = {
createSeparators() {
const separators = [];
for (let i = 0; i < scheduler.days; i++) {
separators.push({location: scheduler.startDate.addDays(i), color: "#ddd"});
}
return separators;
},
async loadData() {
const start = scheduler.visibleStart();
const end = scheduler.visibleEnd();
const promiseResources = DayPilot.Http.get("backend_employees.php?");
const promiseEvents = DayPilot.Http.get(`backend_events.php?start=${start}&end=${end}`);
// wait for both promises
const [{data: resources}, {data: events}] = await Promise.all([promiseResources, promiseEvents]);
const separators = this.createSeparators();
scheduler.update({resources, events, separators});
},
init() {
app.loadData();
}
};
app.init();
</script>
</body>
</html>
History
September 17, 2024: Upgraded to DayPilot Pro 2024.3; compatibility with PHP 8.3; annual leave records store status (pending, approved); icons and context menu added to records.
June 18, 2021: Upgraded to DayPilot 2021.2; JavaScript code uses ES6+ syntax; system fonts.
November 22, 2024: Upgraded to DayPilot 2020.4.
August 5, 2020: DayPilot 2020.3; jQuery removed; DayPilot.Modal.form() instead of a standalone page.
June 27, 2024: Initial version.