Features

  • Angular 4 frontend
  • Built using Angular Scheduler UI component from DayPilot Pro for JavaScipt
  • Restaurant tables displayed in a tree hierarchy (by location)
  • Table filtering by number of seats
  • Table filtering by availability at a specified time
  • Uses a REST API backend, implemented using PHP/MySQL
  • 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.

Angular Frontend Project

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

For a quick Scheduler project setup see Angular 4 Scheduler 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)

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").

Running 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:

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

Scheduler Configuration

The Scheduler installation (NPM package, <daypilot-scheduler> tag) and basic configuraiton is explained in the Angular 4 Scheduler Quick Start Project tutorial.

See also the documentation on using the Scheduler in Angular 2+.

Scheduler Scale and Time Header Units

angular-restaurant-table-reservation-php-mysql-scale-time-headers.png

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" [events]="events" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler")
  scheduler: DayPilotSchedulerComponent;

  events: any[] = [];

  config: any = {
    // ...
    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-table-data.png

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 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" [events]="events" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler")
  scheduler: DayPilotSchedulerComponent;

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

  constructor(private ds: DataService) {
  }

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

We load the resources from the server-side PHP backend using DataService class:

import { Http, Response } from '@angular/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Rx';
import 'rxjs/Rx';
import {DayPilot} from 'daypilot-pro-angular';

@Injectable()
export class DataService {
  
  constructor(private http : Http){
  }

  getResources(): Observable<any[]> {
    return this.http.get("/api/backend_resources.php").map((response:Response) => response.json());
  }
  
  // ...

}

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-mysql-php-seats.png

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

The row header columns can be specified using rowHeaderColumns property. The default settings automatically adjust 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. We will use onBeforeRowHeaderRender 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" [events]="events" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler")
  scheduler: DayPilotSchedulerComponent;

  events: any[] = [];

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

  constructor(private ds: DataService) {
  }

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

    // ...
  }

  // ...

}

Business Hours

angular-restaurant-table-reservation-business-hours.png

We will limit the displayed timeline to business hours (from 11 AM to 12 AM). 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" [events]="events" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler")
  scheduler: DayPilotSchedulerComponent;

  events: any[] = [];

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

  // ...
  
}

Loading Reservations

angular-restaurant-table-reservation-php-mysql-loading.png

Now it's time to load the table reservation data. We subscribe to getEvents() method of the DataService which loads the data from the server side.

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" [events]="events" #scheduler></daypilot-scheduler>`,
  styles: [``]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler")
  scheduler: DayPilotSchedulerComponent;

  events: any[] = [];

  // ...

  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.events = result;
    });
  }
}

data.service.ts

import { Http, Response } from '@angular/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Rx';
import 'rxjs/Rx';
import {DayPilot} from 'daypilot-pro-angular';

@Injectable()
export class DataService {
  
  constructor(private http : Http){
  }

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

}

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":"2017-06-06T18:00:00","end":"2017-06-06T19:30:00","resource":"1"},
  {"id":"9","text":"Mr. Lee","start":"2017-06-06T18:00:00","end":"2017-06-06T20:00:00","resource":"3"},
  {"id":"10","text":"Mr. Brown","start":"2017-06-06T17:00:00","end":"2017-06-06T19:00:00","resource":"5"}
]

Filtering Tables by Number of Seats

angular-restaurant-table-reservation-php-mysql-filtering-by-seats.png

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 (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" [events]="events" #scheduler></daypilot-scheduler>`,
  styles: [`
  ...
  `]
})
export class SchedulerComponent implements AfterViewInit {

  @ViewChild("scheduler")
  scheduler: DayPilotSchedulerComponent;

  events: any[] = [];

  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: any = {
    // ...
    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.png

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.png

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;

  config: any = {
  
    // ...
  
    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, end1, start2, end2): 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;

  config: any = {
  
    // ...
  
    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";
        // args.cell.backColor = "green";
      }
    },
    
    onBeforeTimeHeaderRender: args => {

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

  };

  // ...

}

angular-restaurant-table-reservation-php-mysql-clear-filter.png

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">X</a>
      </span>
      
    </div>

    <daypilot-scheduler [config]="config" [events]="events" #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. You can switch to a MySQL database by copying "_db_mysql.php" file over "_db.php". Don't forget to edit the file header and adjust the MySQL parameters as needed:

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

// ...

MySQL Database Schema

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`)
);