Features

  • Angular 12 frontend

  • Built using Angular Scheduler UI component from DayPilot Pro for JavaScipt

  • Restaurant tables displayed in a tree hierarchy (by location)

  • New reservations can be created using drag and drop

  • Moving existing reservations using drag and drop (allows changing time and/or table)

  • Table filtering by number of table seats

  • Table filtering by availability at the specified time

  • Uses a REST API backend, implemented using PHP/MySQL

This tutorial is also available for JavaScript/PHP:

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.

Scheduler UI Builder

This project was generated using Scheduler UI Builder. You can use this visual tool to configure the Scheduler appearance and properties and generate a downloadable project.

Angular Frontend Project

The Angular frontend project ("angular-restaurant-php-frontend") is created using Angular CLI and uses Angular 12.

For a quick Scheduler project setup see Angular Scheduler 12 Quick Start Project. This downloadable project includes the boilerplate code required to get started with the Scheduler quickly.

All the scheduling logic is encapsulated in a special module (SchedulerModule). The module can be found in src/app/scheduler directory of the project and includes the following files:

  • scheduler.module.ts (the wrapping module)

  • scheduler.component.ts (UI, main logic)

  • data.service.ts (a service that communicates with the PHP backend using REST API)

The Angular frontend project doesn't include NPM dependencies (node_modules directory). You can download and install the dependencies using npm:

npm install

You can run the Angular project using npm:

npm run start

PHP Backend Project

The PHP backend is a standalone project (angular-restaurant-php-backend).

We will use the embedded PHP web server in PHP to run the backend on port 8090:

Linux:

php -S 127.0.0.1:8090 -t /home/daypilot/tutorials/angular-restaurant-php-backend

Windows:

php.exe -S 127.0.0.1:8090 -t C:\Users\daypilot\tutorials\angular-restaurant-php-backend

During development, the Angular project uses a proxy to make the backend available as /api on the same server:

proxy.conf.json:

{
  "/api": {
    "target": "http://localhost:8090",
    "secure": false
  }
}

The "start" script in package.json applies the proxy configuration. All requests to /api/* will be proxied to the PHP web server running on port 8090.

{
  "name": "angular-restaurant-php-frontend",
  // ...
  "scripts": {
    // ...
    "start": "ng serve --proxy-config proxy.conf.json",
    // ...
  },
}

Scheduler Configuration

The Scheduler installation (installing DayPilot Pro NPM package, using <daypilot-scheduler> tag to display the Scheduler) and basic configuration is explained in the Angular Scheduler Tutorial.

See also the documentation on using the Scheduler in Angular.

Scheduler Scale and Time Header Units

angular restaurant table reservation php mysql scale time headers

First, we will set the scale (cell duration) of the Scheduler to 15 minutes and set the time header rows to display days/hours/minutes. This will let the users schedule the restaurant table reservations in 15-minute blocks (snap-to-grid is enabled by default).

scheduler.component.ts

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService, CreateEventParams, MoveEventParams, UpdateEventParams} from "./data.service";{}

@Component({
  selector: 'scheduler-component',
  template: `<daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler", {static: false})
  scheduler!: DayPilotSchedulerComponent;

  config: DayPilot.SchedulerConfig = {
    // ...
    eventHeight: 40,
    cellWidthSpec: "Fixed",
    cellWidth: 50,
    timeHeaders: [
      {groupBy: "Day", format: "dddd, d MMMM yyyy"},
      {groupBy: "Hour"},
      {groupBy: "Cell", format: "mm"}
    ],
    scale: "CellDuration",
    cellDuration: 15,
    // ...
  };

  // ...
  
}

Loading Restaurant Table Data

angular restaurant table reservation php mysql loading table data

In order to display the restaurant tables on the vertical axis we need to fill the resources array of the config object. This array specifies the hierarchy of resources that will be displayed as rows.

By default, the Scheduler displays a flat list. We want to display the tables in a tree hierarchy so we need to enable it using treeEnabled property.

scheduler.component.ts

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService} from "./data.service";

@Component({
  selector: 'scheduler-component',
  template: `<daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler", {static: false})
  scheduler!: DayPilotSchedulerComponent;

  config: DayPilot.SchedulerConfig = {
    treeEnabled: true,
    resources: []
    // ...
  };

  constructor(private ds: DataService) {
  }

  ngAfterViewInit(): void {
    this.ds.getResources().subscribe(result => this.config.resources = result);
  }
}

The change of the config object is detected by Angular and the Scheduler is updated automatically. This is convenient but the cost of the automatic change detection increases with the number of resources. In order to improve the Angular Scheduler performance, we switch to the direct API for resource (table) loading:

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService} from "./data.service";

@Component({
  selector: 'scheduler-component',
  template: `<daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler", {static: false})
  scheduler!: DayPilotSchedulerComponent;

  config: DayPilot.SchedulerConfig = {
    treeEnabled: true,
    // ...
  };

  constructor(private ds: DataService) {
  }

  ngAfterViewInit(): void {
    this.ds.getResources().subscribe(resources => this.scheduler.control.update({resources}));
  }
}

We load the resources from the server-side PHP backend using DataService class. The getResources() method makes an HTTP request to backend_resources.php.

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Rx';
import 'rxjs';
import {DayPilot} from 'daypilot-pro-angular';
import {HttpClient} from "@angular/common/http";

@Injectable()
export class DataService {

  constructor(private http : HttpClient){
  }

  getResources(): Observable<any[]> {
    return this.http.get("/api/backend_resources.php") as Observable<any>;
  }

  // ...

}

The PHP script (backend_resources.php) loads the tables from a MySQL database and returns a JSON array:

<?php
require_once '_db.php';
    
$scheduler_groups = $db->query('SELECT * FROM groups ORDER BY name');

class Group {}
class Resource {}

$groups = array();

foreach($scheduler_groups as $group) {
  $g = new Group();
  $g->id = "group_".$group['id'];
  $g->name = $group['name'];
  $g->expanded = true;
  $g->children = array();
  $groups[] = $g;
  
  $stmt = $db->prepare('SELECT * FROM resources WHERE group_id = :group ORDER BY name');
  $stmt->bindParam(':group', $group['id']);
  $stmt->execute();
  $scheduler_resources = $stmt->fetchAll();  
  
  foreach($scheduler_resources as $resource) {
    $r = new Resource();
    $r->id = $resource['id'];
    $r->name = $resource['name'];
    $r->seats = $resource['seats'];
    $g->children[] = $r;
  }
}

header('Content-Type: application/json');
echo json_encode($groups);

?>

Sample JSON response:

[
  {"id":"group_1","name":"Indoors","expanded":true,"children":[
    {"id":"1","name":"Table 1","seats":"2"},
    {"id":"2","name":"Table 2","seats":"3"},
    {"id":"3","name":"Table 3","seats":"4"},
    {"id":"4","name":"Table 4","seats":"4"},
    {"id":"5","name":"Table 5","seats":"6"},
    {"id":"6","name":"Table 6","seats":"6"}
  ]},
  {"id":"group_2","name":"Terrace","expanded":true,"children":[
    {"id":"7","name":"Table 21","seats":"2"},
    {"id":"8","name":"Table 22","seats":"2"},
    {"id":"9","name":"Table 23","seats":"5"},
    {"id":"10","name":"Table 24","seats":"5"},
    {"id":"11","name":"Table 25","seats":"6"},
    {"id":"12","name":"Table 26","seats":"6"}
  ]}
]

Displaying Table Seats

angular restaurant table reservation php mysql seats

In the next step, we will add a column to the row header that will display a number of seats for every table.

The row header columns can be specified using rowHeaderColumns property. In the default configuration, the Scheduler automatically adjusts the column width to match the content (row header width auto-fit is enabled).

The actual number of seats for every table is available in the resources array (see backend_resources.php script) as seats property of each child item. We will use onBeforeRowHeaderRender event handler to display and format the column HTML.

scheduler.component.ts

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService, CreateEventParams, MoveEventParams, UpdateEventParams} from "./data.service";

@Component({
  selector: 'scheduler-component',
  template: `<daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler", {static: false})
  scheduler!: DayPilotSchedulerComponent;

  config: DayPilot.SchedulerConfig = {
    // ...
    rowHeaderColumns: [
      {title: "Table", display: "name"},
      {title: "Seats"}
    ],
    onBeforeRowHeaderRender: args => {
      if (args.row.data.seats && args.row.columns[0]) {
        args.row.columns[1].html = args.row.data.seats + " seats";
      }
    },
  };

  constructor(private ds: DataService) {
  }

  ngAfterViewInit(): void {
    this.ds.getResources().subscribe(resources => this.scheduler.control.update({ resources }));

    // ...
  }

  // ...

}

Business Hours

angular restaurant table reservation business hours

We will limit the displayed timeline to business hours (from 11 AM to 12 AM). The restaurant business hours can be defined using businessBeginsHour and businessEndsHour properties. By default, the non-business hours are visible and have a different cell background color. The Angular Scheduler component can hide the non-business hours - instead of the the non-business hours, the Scheduler will display a vertical line that indicates that the timeline is interrupted. See also Hiding Non-Business Hours.

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService, CreateEventParams, MoveEventParams, UpdateEventParams} from "./data.service";

@Component({
  selector: 'scheduler-component',
  template: `<daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler", {static: false})
  scheduler!: DayPilotSchedulerComponent;

  config: DayPilot.SchedulerConfig = {
    // ...
    businessBeginsHour: 11,
    businessEndsHour: 24,
    showNonBusiness: false,
    // ...
  };

  // ...
  
}

Loading Table Reservations

angular restaurant php mysql loading reservations

Now it's time to load the table reservation data. We subscribe to getEvents() method of the DataService class which loads the data from the server side. We limit the event date by the Scheduler grid start (visibleStart() method) and end (visibleEnd() method).

While it is possible to use the Angular change detection and load the data using the [events] attribute of the <daypilot-scheduler> tag, we will use the direct API to load the reservation data to improve performance.

scheduler.component.ts

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService} from "./data.service";

@Component({
  selector: 'scheduler-component',
  template: `<daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler", {static: false})
  scheduler!: DayPilotSchedulerComponent;

  // ...

  constructor(private ds: DataService) {
  }

  ngAfterViewInit(): void {
    // ...

    var from = this.scheduler.control.visibleStart();
    var to = this.scheduler.control.visibleEnd();
    this.ds.getEvents(from, to).subscribe(result => {
      this.scheduler.control.update({events});
    });
  }
}

data.service.ts

import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {DayPilot} from 'daypilot-pro-angular';
import {HttpClient} from "@angular/common/http";

@Injectable()
export class DataService {

  constructor(private http : HttpClient){
  }

  getEvents(from: DayPilot.Date, to: DayPilot.Date): Observable<any[]> {
    return this.http.get("/api/backend_events.php?from=" + from.toString() + "&to=" + to.toString()) as Observable<any>;
  }

  // ...

}

The backend_events.php script loads the table reservations from a database and returns a JSON array. Only the reservations for the selected time range (from and to query string parameters) are loaded.

<?php
require_once '_db.php';

$stmt = $db->prepare('SELECT * FROM events WHERE NOT ((end <= :start) OR (start >= :end))');
$stmt->bindParam(':start', $_GET["from"]);
$stmt->bindParam(':end', $_GET["to"]);
$stmt->execute();
$result = $stmt->fetchAll();

class Event {}
$events = array();

foreach($result as $row) {
  $e = new Event();
  $e->id = $row['id'];
  $e->text = $row['name'];
  $e->start = $row['start'];
  $e->end = $row['end'];
  $e->resource = $row['resource_id'];
  $events[] = $e;
}

header('Content-Type: application/json');
echo json_encode($events);

?>

Sample JSON response:

[
  {"id":"8","text":"Ms. Williams","start":"2019-06-06T18:00:00","end":"2019-06-06T19:30:00","resource":"1"},
  {"id":"9","text":"Mr. Lee","start":"2019-06-06T18:00:00","end":"2019-06-06T20:00:00","resource":"3"},
  {"id":"10","text":"Mr. Brown","start":"2019-06-06T17:00:00","end":"2019-06-06T19:00:00","resource":"5"}
]

We need to load both the table and reservation data in ngAfterViewInit(). We have used two separate HTTP calls (/api/backend_events.php for reservations and /api/backend_resources.php for tables).

So far, the HTTP calls were executed independently and the Scheduler was updated after each HTTP call was finished. This causes two update of the Angular Scheduler component which is redundant. In the next steps, we will execute the HTTP calls in parallel and wait for both of them to complete before updating the Scheduler (just once):

async ngAfterViewInit(): Promise<void> {

  const promiseResources = this.ds.getResources().toPromise();

  const from = this.scheduler.control.visibleStart();
  const to = this.scheduler.control.visibleEnd();
  const promiseEvents = this.ds.getEvents(from, to).toPromise();

  const [resources, events] = await Promise.all([promiseResources, promiseEvents]);

  this.scheduler.control.update({
    resources,
    events
  });
}

Filtering Tables by Number of Seats

angular restaurant table reservation php mysql filtering by seats

Now we are going to add a filter that helps users find a free table with a specified number of seats.

The filter is implemented as a simple drop-down list:

<div class="filter">
  Filter:
  <select (change)="seatFilterChange($event);" [(ngModel)]="seatFilter">
    <option *ngFor="let it of seats" [ngValue]="it.value">{{it.name}}</option>          
  </select>
</div>

We will watch the <select> element for changes (see seatFilter() method) and use row filtering feature of the Scheduler to hide rows that don't meet the selected criteria.

The rows.filter() method requires the filter parameter as an argument. However, we replace it with an empty object ({}) and read the filter parameters directly from this.seatFilter. This will make it easier to add a filter with multiple conditions in the next step (see Filtering Tables by Time Slot below). The empty object is necessary because calling rows.filter() without an argument clears the filter.

The Scheduler uses onRowFilter event handler to determine whether a row should be visible or not. In this event handler, we use the row metadata (number of seats available as args.row.data.seats) to check the condition.

See also the row filtering tutorial:

scheduler.component.ts

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService, CreateEventParams, MoveEventParams, UpdateEventParams} from "./data.service";

@Component({
  selector: 'scheduler-component',
  template: `
<div class="filter">
  Filter:
  <select (change)="seatFilterChange($event);" [(ngModel)]="seatFilter">
    <option *ngFor="let it of seats" [ngValue]="it.value">{{it.name}}</option>          
  </select>  
</div>

<daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [`
  ...
  `]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler")
  scheduler!: DayPilotSchedulerComponent;

  seats: any[] = [
    { name: "All", value: 0},
    { name: "2+ seats", value: 2},
    { name: "3+ seats", value: 3},
    { name: "4+ seats", value: 4},
    { name: "5+ seats", value: 5},
    { name: "6 seats", value: 6},
  ];

  seatFilter: number = 0;

  config: DayPilot.SchedulerConfig = {
    // ...
    onRowFilter: args => {
      let seatsMatching = args.row.data.seats >= this.seatFilter;      

      args.visible = seatsMatching;
    },
  };

  constructor(private ds: DataService) {
  }

  seatFilterChange(ev): void {
    this.scheduler.control.rows.filter({});
  }

  // ...

}

Filtering Tables by Time Slot

angular restaurant table reservation php mysql filtering by time

We will add one more way to filter the tables: by a specified time slot. After selecting a time slot the Scheduler will hide tables (rows) that are not available at that time. The selected time slot will be highlighted (green background): 

angular restaurant table reservation php mysql filtering by time results

First, we add an event handler for onTimeHeaderClick event. This will activate the filter for the given time range.

We also expand the onRowFilter event handler to use both the number of seats and the time range during filtering.

// ...
export class SchedulerComponent implements AfterViewInit {

  // ...

  timeFilter: {start: DayPilot.Date, end: DayPilot.Date } | null  = null;

  config: DayPilot.SchedulerConfig = {
  
    // ...
  
    onTimeHeaderClick: args => {

      this.timeFilter = {
        start: args.header.start,
        end: args.header.end
      };

      this.scheduler.control.rows.filter({});
    },
    
    onRowFilter: args => {
      let seatsMatching = args.row.data.seats >= this.seatFilter;
      let timeMatching = !this.timeFilter || !args.row.events.all().some(e => this.overlaps(e.start(), e.end(), this.timeFilter.start, this.timeFilter.end));

      args.visible = seatsMatching && timeMatching;
    },
    
    // ...

  };

  // ...

  overlaps(start1: DayPilot.Date, end1: DayPilot.Date, start2: DayPilot.Date, end2: DayPilot.Date): boolean {
    return !(end1 <= start2 || start1 >= end2);
  }


}

The selected time range will be highlighted using onBeforeTimeHeaderRender(time headers) and onBeforeCellRender (grid cells) event handlers. It applies a custom CSS class to headers/cells that overlap with the selection.

// ...
export class SchedulerComponent implements AfterViewInit {

  // ...

  timeFilter: {start: DayPilot.Date, end: DayPilot.Date } | null = null;

  config: DayPilot.SchedulerConfig = {
  
    // ...
  
    onBeforeCellRender: args => {
      if (!this.timeFilter) {
        return;
      }
      if (this.overlaps(args.cell.start, args.cell.end, this.timeFilter.start, this.timeFilter.end)) {
        args.cell.cssClass = "cell_selected";
      }
    },
    
    onBeforeTimeHeaderRender: args => {

      if (this.timeFilter) {
        if (args.header.start >= this.timeFilter.start && args.header.end <= this.timeFilter.end) {
          args.header.cssClass = "timeheader_selected";
        }
      }
    },
    
    // ...

  };

  // ...

}

angular restaurant table reservation php mysql clear filter

Users can clear the time filter by clicking the "X" link in the filter label. This fires clearTimeFilter() method:

import {Component, ViewChild, AfterViewInit} from "@angular/core";
import {DayPilot, DayPilotSchedulerComponent} from "daypilot-pro-angular";
import {DataService, CreateEventParams, MoveEventParams, UpdateEventParams} from "./data.service";

@Component({
  selector: 'scheduler-component',
  template: `
    <div class="filter">
      Filter:
      <select (change)="seatFilterChange($event);" [(ngModel)]="seatFilter">
        <option *ngFor="let it of seats" [ngValue]="it.value">{{it.name}}</option>          
      </select>
      
      <span *ngIf="timeFilter" class="timefilter">
      {{timeFilter?.start?.toString("d/M/yyyy")}}
      {{timeFilter?.start?.toString("h:mm tt")}} - {{timeFilter?.end?.toString("h:mm tt")}}
      <a href="#" (click)="clearTimeFilter()" class="remove">&times;</a>
      </span>
      
    </div>

    <daypilot-scheduler [config]="config" #scheduler></daypilot-scheduler>`,
  styles: [`...`]
})
export class SchedulerComponent implements AfterViewInit {

  // ...

  clearTimeFilter(): boolean {
    this.timeFilter = null;
    this.scheduler.control.update();
    return false;
  }

}

MySQL Support

By default, the PHP backend project uses a local SQLite database. This is helpful for quick testing but the capabilities of SQLite database are limited.

You can switch to a MySQL database by editing the _db.php file:

<?php

// use sqlite
// require_once '_db_sqlite.php';

// use MySQL
require_once '_db_mysql.php';

Don't forget to edit the file header and adjust the MySQL connection parameters as needed:

_db_mysql.php

<?php
$host = "127.0.0.1";
$port = 3306;
$username = "username";
$password = "password";
$database = "restaurant";   // the database will be created if it doesn't exist

// ...

The MySQL database specified using the $database variable ("restaurant" by default) will be created automatically. However, make sure that the specified user has permissions to create/access the database.

MySQL Database Schema

The project uses a simple database schema with three tables:

  • groups table defines table groups

  • resources table defines tables

  • events table stores reservation data

CREATE TABLE `events` (
	`id` INT(11) NOT NULL AUTO_INCREMENT,
	`name` TEXT NULL,
	`start` DATETIME NULL DEFAULT NULL,
	`end` DATETIME NULL DEFAULT NULL,
	`resource_id` VARCHAR(30) NULL DEFAULT NULL,
	PRIMARY KEY (`id`)
);

CREATE TABLE `groups` (
	`id` INT(11) NOT NULL,
	`name` VARCHAR(200) NULL DEFAULT NULL,
	PRIMARY KEY (`id`)
);

CREATE TABLE `resources` (
	`id` INT(11) NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(200) NULL DEFAULT NULL,
	`seats` INT(11) NULL DEFAULT NULL,
	`group_id` INT(11) NULL DEFAULT NULL,
	PRIMARY KEY (`id`)
);

History

  • June 15, 2021: Upgraded to Angular 12, DayPilot Pro for JavaScript 2021.2.5009; using system fonts; using direct API for table and reservation loading

  • November 26, 2020: Upgraded to Angular 11, DayPilot Pro for JavaScript 2020.4.4766.

  • August 3, 2020: Upgraded to Angular 10, DayPilot Pro for JavaScript 2020.3.

  • May 22, 2020: Upgraded to Angular 9, DayPilot Pro for JavaScript 2020.2.4470, tabular mode for row headers

  • June 8, 2019: Upgraded to Angular 8, DayPilot Pro for JavaScript 2019.2.3871

  • June 3, 2018: Upgraded to Angular 6, DayPilot Pro for JavaScript 2018.2.3297

  • February 17, 2018: Upgraded to Angular 5, DayPilot Pro for JavaScript 2018.1.3169

  • June 6, 2017: Initial release (Angular 4)