Overview
Record restaurant table reservations using a visual interface
Find free tables easily (filter by time and number of seats)
Tables grouped by location (e.g. Indoors, Terrace)
HTML5/JavaScript frontend and PHP REST backend
MySQL database (the database is create and initialized automatically on first load)
SQLite database for no-installation testing
Built using a JavaScript Scheduler UI component from DayPilot Pro for JavaScript
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.
Find Available Restaurant Tables by Seats
This restaurant table reservation application lets you filter the tables by number of seats. When looking for a free table, you can select the required number of seats using a drop-down list and the Scheduler will only display the matching tables.
Find Tables Available at Specific Time
If you are looking for a table that is free at the specified time you can simply click the header. The tables (rows) that are not available will be hidden. The time filter can also be combined with the seats filter.
Create a Table Reservation using Drag and Drop
You can create a new table reservation by simply selecting the desired time in the Scheduler. You can enter the reservation details using a modal dialog.
HTML5/JavaScript Scheduler Introduction
This application uses the JavaScript Scheduler component from DayPilot Pro for JavaScript package.
For an introduction to using the JavaScript/HTML5 Scheduler, please see the following tutorial:
You can also create a blank project with a pre-configured Scheduler using the UI Builder online application.
We will continue with features that are specific to restaurant table reservation.
Load Restaurant Tables Data (Name, Seats)
The restaurant tables will be displayed as rows in the Scheduler component.
The tables are grouped by location - our example uses two locations (“Indoors” and “Terrace)”. Users can collapse selection locations that are not in use.
Each row displays two columns (table name and number of seats). The columns are defined using rowHeaderColumns property:
var dp = new DayPilot.Scheduler("dp", {
// ...
rowHeaderColumns: [
{title: "Table", display: "name"},
{title: "Seats", display: "seats"}
],
// ...
});
We will load the table data from the server using rows.load() method:
dp.rows.load("reservation_tables.php");
Here is a sample response of reservation_tables.php
script. It’s a JSON array that follows the structure required for the resources property of the Scheduler component.
[
{
"id": "location_1",
"name": "Indoors",
"expanded": true,
"cellsDisabled": true,
"children": [
{
"id": "1",
"name": "Table 1",
"seats": "2"
},
{
"id": "2",
"name": "Table 2",
"seats": "2"
},
{
"id": "3",
"name": "Table 3",
"seats": "2"
},
{
"id": "4",
"name": "Table 4",
"seats": "4"
}
]
},
{
"id": "location_2",
"name": "Terrace",
"expanded": true,
"cellsDisabled": true,
"children": [
{
"id": "5",
"name": "Table 5",
"seats": "4"
},
{
"id": "6",
"name": "Table 6",
"seats": "6"
},
{
"id": "7",
"name": "Table 7",
"seats": "4"
},
{
"id": "8",
"name": "Table 8",
"seats": "4"
}
]
}
]
PHP source of the reservation_tables.php
script:
<?php
require_once '_db.php';
$db_locations = $db->query('SELECT * FROM locations ORDER BY name');
class Location {}
class Table {}
$locations = array();
foreach($db_locations as $location) {
$g = new Location();
$g->id = "location_".$location['id'];
$g->name = $location['name'];
$g->expanded = true;
$g->cellsDisabled = true;
$g->children = array();
$locations[] = $g;
$stmt = $db->prepare('SELECT * FROM tables WHERE location_id = :location ORDER BY name');
$stmt->bindParam(':location', $location['id']);
$stmt->execute();
$db_tables = $stmt->fetchAll();
foreach($db_tables as $table) {
$r = new Table();
$r->id = $table['id'];
$r->name = $table['name'];
$r->seats = $table['seats'];
$g->children[] = $r;
}
}
header('Content-Type: application/json');
echo json_encode($locations);
We have used "seats"
as the display
field for the second column - that means the value of seats
property of the resources[]
items will be displayed in the second row header column automatically.
We want to make it a bit more readable by adding “seats” text to the column details. We will use onBeforeRowHeaderRender to customize the text:
onBeforeRowHeaderRender: function(args) {
if (args.row.data.seats && args.row.columns[1]) {
args.row.columns[1].html = args.row.data.seats + " seats";
}
},
Time Header and Business Hours
We will configure the Scheduler to display the current week using startDate and days properties:
startDate: DayPilot.Date.today().firstDayOfWeek(),
days: 7,
The time header will display three rows (days, hours, minutes):
timeHeaders: [{groupBy: "Day", format: "dddd, d MMMM yyyy"}, {groupBy: "Hour"}, {groupBy: "Cell", format: "mm"}],
The Scheduler grid cell duration will be 15 minutes:
scale: "CellDuration",
cellDuration: 15,
Now we can configure the business hours of our restaurant (from 11 am to 12 am/midnight).
businessBeginsHour: 11,
businessEndsHour: 24,
The restaurant will be closed outside of the business hours so we will exclude this time from the time line:
showNonBusiness: false,
The restaurant will be open on weekends but the Scheduler doesn’t include weekends in the business hours by default. We need to overrride this setting:
businessWeekends: true,
And this is how the time header configuration looks now:
var dp = new DayPilot.Scheduler("dp", {
// ...
startDate: DayPilot.Date.today().firstDayOfWeek(),
days: 7,
timeHeaders: [{groupBy: "Day", format: "dddd, d MMMM yyyy"}, {groupBy: "Hour"}, {groupBy: "Cell", format: "mm"}],
scale: "CellDuration",
cellDuration: 15,
businessBeginsHour: 11,
businessEndsHour: 24,
businessWeekends: true,
showNonBusiness: false,
// ...
});
Create Restaurant Reservations
The Scheduler makes is easy to create new reservations using drag and drop. This feature is enabled by default and we just need to add our own onTimeRangeSelected event handler:
var dp = new DayPilot.Scheduler("dp", {
// ...
onTimeRangeSelected: function (args) {
DayPilot.Modal.prompt("Create a new reservation:", "Reservation 1").then(function (modal) {
dp.clearSelection();
if (modal.canceled) {
return;
}
var params = {
start: args.start,
end: args.end,
resource: args.resource,
text: modal.result
};
DayPilot.Http.ajax({
url: "reservation_create.php",
data: params,
success: function (ajax) {
var ev = params;
ev.id = ajax.data.id;
dp.events.add(ev);
dp.message("Reservation created");
},
});
});
},
// ...
});
The onTimeRangeSelected
event handler opens a modal dialog using DayPilot.Modal.prompt()
- that is a simple replacement for the built-in prompt() function - and asks for the reservation description.
Then it calls reservation_create.php
script that saves the new reservation in the database and returns the reservation ID:
<?php
require_once '_db.php';
$json = file_get_contents('php://input');
$params = json_decode($json);
$stmt = $db->prepare("INSERT INTO reservations (name, start, end, table_id) VALUES (:name, :start, :end, :table)");
$stmt->bindParam(':start', $params->start);
$stmt->bindParam(':end', $params->end);
$stmt->bindParam(':name', $params->text);
$stmt->bindParam(':table', $params->resource);
$stmt->execute();
class Result {}
$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);
As soon as the HTTP call completes, the event handler adds the reservation to the Scheduler using events.add() method.
Filter Tables by Capacity (Seats)
First, we add a drop-down list to the HTML page (using <select>
element) with the filtering options:
Filter:
<select class="seatfilter">
<option value="0">All</option>
<option value="3">3+ seats</option>
<option value="4">4+ seats</option>
<option value="6">6+ seats</option>
</select>
Now we need to activate the <select>
list. We add a handler for “change” event which will store the selected value in a global seatFilter
variable and request the row filter to be applied using rows.filter() method.
Normally, you would pass the filter parameter as an argument to the rows.filter()
method. However, we are going to use a complex filter (the available time filter will be added in the next step) so we store the filter parameter in a global variable and pass an empty object to rows.filter(). Using null as the rows.filter()
argument would clear the filter and display all rows.
var seatFilter = 0;
function activateSeatFilter() {
var filter = document.querySelector(".seatfilter");
filter.addEventListener("change", function(ev) {
seatFilter = parseInt(filter.value, 10);
dp.rows.filter({});
});
}
The selected value is checked in the onRowFilter event handler when the row is applied. The row visibility is determined by the value of args.visible
which is set to true
by default. We check if the table/row has the required number of seats and set the args.visible
value accordingly.
var dp = new DayPilot.Scheduler("dp", {
// ...
onRowFilter: function(args) {
var seatsMatching = seatFilter === 0 || args.row.data.seats >= seatFilter;
args.visible = seatsMatching;
},
// ...
});
Read more about Row filtering in the documentation.
Filter Tables by Available Time
We will use the time header customization features of the JavaScript Scheduler to add custom logic.
The onBeforeTimeHeaderRender event handler lets us add custom active areas to the header cells using args.header.areas
property.
In this case, we create two areas:
One big area with green background that covers the whole time cell
Another area that displays an icon at the right side
var dp = new DayPilot.Scheduler("dp", {
onBeforeTimeHeaderRender: function(args) {
args.header.toolTip = 'Filter by time';
args.header.areas = [
{ left: 0, top: 0, bottom: 0, right: 0, backColor: "green", style: "opacity: 0.5; cursor: pointer;", visibility: "Hover"},
{ right: 0, top: 7, width: 15, bottom: 20, html: '▼', visibility: "Hover"}
];
},
// ...
});
Now we can add a new onTimeHeaderClick event handler that will apply the time filter. It stores the header cell start and end in the timeFilter
global variable and calls rows.filter()
method to activate the filter.
var timeFilter = null;
// ...
var dp = new DayPilot.Scheduler("dp", {
onTimeHeaderClick: function(args) {
timeFilter = {
start: args.header.start,
end: args.header.end
};
updateTimeFilter();
dp.rows.filter({});
},
// ...
});
We need to extend the row filter implementation in onRowFilter
event handler to include the time filter:
var dp = new DayPilot.Scheduler("dp", {
// ...
onRowFilter: function(args) {
var seatsMatching = seatFilter === 0 || args.row.data.seats >= seatFilter;
var timeMatching = !timeFilter || !args.row.events.all().some(function(e) { return overlaps(e.start(), e.end(), timeFilter.start, timeFilter.end); }) ;
args.visible = seatsMatching && timeMatching;
},
// ...
});
MySQL Database Schema
By default, the project uses SQLite database. You can switch to MySQL by editing _db.php
file to look like this:
<?php
// use sqlite
// require_once '_db_sqlite.php';
// use MySQL
require_once '_db_mysql.php';
You need to edit the _db_mysql.php
file and edit the database name, username and password.
<?php
$host = "127.0.0.1";
$port = 3306;
$username = "username";
$password = "password";
$database = "restaurant";
// ...
The MySQL database will be initialized automatically with the following schema.
reservations
table:
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL,
name TEXT,
start DATETIME,
end DATETIME,
table_id VARCHAR(30)
);
locations
table:
CREATE TABLE locations (
id INTEGER PRIMARY KEY NOT NULL,
name VARCHAR(200) NULL
);
tables
table:
CREATE TABLE tables (
id INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL,
name VARCHAR(200) NULL,
location_id INTEGER NULL
);
Full Source Code
And here is the full source code of the client-side (JavaScript/HTML5) part of our restaurant table reservation application:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>PHP Restaurant Table Reservation</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
/* ... */
</style>
<style>
.filter {
margin: 10px 0px;
font-size: 14px;
}
.filter select {
padding: 5px;
font-size: 14px;
}
.timefilter {
display: inline-block;
background-color: #6aa84f;
color: white;
border-radius: 5px;
padding: 5px 10px;
margin-left: 10px;
}
.timefilter a.timefilter-clear {
display: inline-block;
margin-left: 15px;
font-weight: bold;
text-decoration: none;
color: white;
}
#dp .timeheader_selected .scheduler_default_timeheader_cell_inner {
background-color: #93c47d;
}
#dp .cell_selected.scheduler_default_cell,
#dp .cell_selected.scheduler_default_cell.scheduler_default_cell_business {
background-color: #b6d7a8;
}
#dp .scheduler_default_event_inner {
padding: 5px;
}
#dp .scheduler_default_event_float_inner::after {
border-color: transparent white transparent transparent;
}
</style>
</head>
<body>
<div class="header">
<h1><a href="https://code.daypilot.org/97699/php-restaurant-table-reservation">PHP Restaurant Table Reservation</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 class="filter">
Filter:
<select class="seatfilter">
<option value="0">All</option>
<option value="3">3+ seats</option>
<option value="4">4+ seats</option>
<option value="6">6+ seats</option>
</select>
<span class="timefilter">
<span class="timefilter-text"></span>
<a href="#" class="timefilter-clear">×</a>
</span>
</div>
<div id="dp"></div>
</div>
<!-- DayPilot library -->
<script src="js/daypilot/daypilot-all.min.js"></script>
<script>
var seatFilter = 0;
var timeFilter = null;
var dp = new DayPilot.Scheduler("dp", {
eventHeight: 40,
cellWidthSpec: "Fixed",
cellWidth: 50,
timeHeaders: [{groupBy: "Day", format: "dddd, d MMMM yyyy"}, {groupBy: "Hour"}, {groupBy: "Cell", format: "mm"}],
scale: "CellDuration",
cellDuration: 15,
days: 7,
startDate: DayPilot.Date.today().firstDayOfWeek(),
timeRangeSelectedHandling: "Enabled",
treeEnabled: true,
scrollTo: new DayPilot.Date(),
heightSpec: "Max",
height: 400,
durationBarVisible: false,
rowHeaderColumns: [
{title: "Table", display: "name"},
{title: "Seats", display: "seats"}
],
businessBeginsHour: 11,
businessEndsHour: 24,
businessWeekends: true,
showNonBusiness: false,
onTimeRangeSelected: function (args) {
DayPilot.Modal.prompt("Create a new reservation:", "Reservation 1").then(function (modal) {
dp.clearSelection();
if (modal.canceled) {
return;
}
var params = {
start: args.start,
end: args.end,
resource: args.resource,
text: modal.result
};
DayPilot.Http.ajax({
url: "reservation_create.php",
data: params,
success: function (ajax) {
var ev = params;
ev.id = ajax.data.id;
dp.events.add(ev);
dp.message("Reservation created");
},
});
});
},
onEventClick: function (args) {
DayPilot.Modal.prompt("Edit a reservation:", args.e.text()).then(function (modal) {
if (modal.canceled) {
return;
}
var params = {
id: args.e.id(),
text: modal.result
};
DayPilot.Http.ajax({
url: "reservation_update.php",
data: params,
success: function (ajax) {
args.e.data.text = params.text;
dp.events.update(args.e);
}
});
});
},
onBeforeRowHeaderRender: function (args) {
if (args.row.data.seats && args.row.columns[1]) {
args.row.columns[1].html = args.row.data.seats + " seats";
}
},
onRowFilter: function (args) {
var seatsMatching = seatFilter === 0 || args.row.data.seats >= seatFilter;
var timeMatching = !timeFilter || !args.row.events.all().some(function (e) {
return overlaps(e.start(), e.end(), timeFilter.start, timeFilter.end);
});
args.visible = seatsMatching && timeMatching;
},
onTimeHeaderClick: function (args) {
timeFilter = {
start: args.header.start,
end: args.header.end
};
updateTimeFilter();
dp.rows.filter({});
},
onBeforeCellRender: function (args) {
if (!timeFilter) {
return;
}
if (overlaps(args.cell.start, args.cell.end, timeFilter.start, timeFilter.end)) {
args.cell.cssClass = "cell_selected";
// args.cell.backColor = "green";
}
},
onBeforeTimeHeaderRender: function (args) {
args.header.toolTip = "Filter by time";
args.header.areas = [
{
left: 0,
top: 0,
bottom: 0,
right: 0,
backColor: "green",
style: "opacity: 0.5; cursor: pointer;",
visibility: "Hover"
},
{right: 0, top: 7, width: 15, bottom: 20, html: "▼", style: "color: #274e13;", visibility: "Hover"}
];
if (timeFilter) {
if (args.header.start >= timeFilter.start && args.header.end <= timeFilter.end) {
args.header.cssClass = "timeheader_selected";
// args.header.backColor = "darkgreen";
// args.header.fontColor = "white";
}
}
},
onBeforeEventRender: function(args) {
args.data.backColor = "#3d85c6";
args.data.borderColor = "darker";
args.data.fontColor = "white";
args.data.areas = [
{
right: 4,
top: 9,
height: 22,
width: 22,
cssClass: "scheduler_default_event_delete",
style: "background-color: #fff; border: 1px solid #ccc; box-sizing: border-box; border-radius: 11px; padding: 0px;",
visibility: "Visible",
action: "None",
onClick: function (args) {
var e = args.source;
DayPilot.Modal.confirm("Delete this reservation?").then(function (modal) {
if (modal.canceled) {
return;
}
DayPilot.Http.ajax({
url: "reservation_delete.php",
data: {id: e.data.id},
success: function (ajax) {
dp.events.remove(e.data.id);
}
});
});
}
}
];
},
onEventMoved: function (args) {
var params = {
id: args.e.id(),
start: args.newStart,
end: args.newEnd,
resource: args.newResource
};
DayPilot.Http.ajax({
url: "reservation_move.php",
data: params,
success: function (ajax) {
dp.message("Reservation updated");
},
});
},
onEventResized: function (args) {
var params = {
id: args.e.id(),
start: args.newStart,
end: args.newEnd,
resource: args.e.resource()
};
DayPilot.Http.ajax({
url: "reservation_move.php",
data: params,
success: function (ajax) {
dp.message("Reservation updated");
},
});
},
});
dp.init();
dp.rows.load("reservation_tables.php");
dp.events.load("reservation_list.php");
activateTimeFilter();
activateSeatFilter();
updateTimeFilter();
function overlaps(start1, end1, start2, end2) {
return !(end1 <= start2 || start1 >= end2);
}
function updateTimeFilter() {
var span = document.querySelector(".timefilter");
if (!timeFilter) {
span.style.display = "none";
return;
}
var inner = document.querySelector(".timefilter-text");
var text = `${timeFilter?.start.toString("d/M/yyyy")} ${timeFilter?.start.toString("h:mm tt")} - ${timeFilter?.end.toString("h:mm tt")}`;
inner.innerText = text;
span.style.display = "";
}
function activateTimeFilter() {
var clear = document.querySelector(".timefilter-clear");
clear.addEventListener("click", function (ev) {
ev.preventDefault();
timeFilter = null;
updateTimeFilter();
dp.rows.filter({});
});
}
function activateSeatFilter() {
var filter = document.querySelector(".seatfilter");
filter.addEventListener("change", function (ev) {
seatFilter = parseInt(filter.value, 10);
dp.rows.filter({});
});
}
</script>
</body>
</html>