Overview

  • Record restaurant table reservations

  • Find free table 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

  • Includes a trial version of DayPilot Pro for JavaScript (see License below)

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.

Find Available Restaurant Tables by Seats

php-restaurant-table-reservation-system-javascript-html5-filter-by-seats.png

Our restaurant table reservation application will let 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

php-restaurant-table-reservation-system-javascript-html5-availability-time.png

If you are looking for a table that is free at a specified time you can simply click the header. The tables 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

php-restaurant-table-reservation-system-javascript-html5-book-a-table.png

You can create a new table reservation by simply selecting the required time in the Scheduler. Enter the reservation details using a modal dialog.

HTML5/JavaScript Scheduler Introduction

html5-scheduler-javascript-php-mysql.png

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)

php-restaurant-table-reservation-system-javascript-html5-seats.png

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 if they 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 of 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 number. 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

php-restaurant-table-reservation-system-time-line.png

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

php-restaurant-table-reservation-system-new-booking.png

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.

php-restaurant-table-reservation-system-prompt-dialog.png

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)

php-restaurant-table-reservation-system-javascript-html5-seats-filter.png

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

php-restaurant-table-reservation-system-javascript-html5-time-filter.png

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: '&#9660;', 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">&times;</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: "&#9660;", 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>