Dans un fil récent de forum il y a eu une question intéressante à laquelle je pensais répondre rapidement et puis… j’ai commencé à faire un petit essai qui m’a mené plus loin que prévu. Etant donné l’ampleur qu’à pris la réponse je me suis dit qu’elle pouvait faire l’objet d’un article.

L’essentiel du problème réside dans la gestion de plages horaires d’ouverture de restaurants. J’ai traité le problème d’une certaine façon et il doit y en avoir bien d’autres. D’autre part je n’ai pas poussé le code pour avoir quelque chose de parfait mais juste pour montrer une direction.

Le code complet est disponible dans un zip ici.

La base de données

On a 3 tables :

img76

Avec une relation de type n:n (many to many) entre restaurants et days. La table pivot n’a pas un nom conventionnel puisqu’elle s’appelle workhours (elle devrait se nommer day_restaurant) mais ce n’est pas un problème.

La table pivot comporte deux champs pour les plages horaires :

  • start_time
  • end_time

Avec cette architecture on peut donc avoir un nombre illimité de plages d’ouverture. Autant partir sur une solution généraliste…

Ce qui donne cette migration pour days :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateDaysTable extends Migration {

	public function up()
	{
		Schema::create('days', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->string('name');
		});
	}

	public function down()
	{
		Schema::drop('days');
	}
}

Celle-ci pour restaurants :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateRestaurantsTable extends Migration {

	public function up()
	{
		Schema::create('restaurants', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->string('name');
		});
	}

	public function down()
	{
		Schema::drop('restaurants');
	}
}

Pour la table pivot :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateWorkhoursTable extends Migration {

	public function up()
	{
		Schema::create('workhours', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->integer('restaurant_id')->unsigned();
			$table->integer('day_id')->unsigned();
			$table->time('start_time');
			$table->time('end_time');
		});
	}

	public function down()
	{
		Schema::drop('workhours');
	}
}

Et enfin les clés étrangères :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Eloquent\Model;

class CreateForeignKeys extends Migration {

	public function up()
	{
		Schema::table('workhours', function(Blueprint $table) {
			$table->foreign('restaurant_id')->references('id')->on('restaurants')
						->onDelete('restrict')
						->onUpdate('restrict');
		});
		Schema::table('workhours', function(Blueprint $table) {
			$table->foreign('day_id')->references('id')->on('days')
						->onDelete('restrict')
						->onUpdate('restrict');
		});
	}

	public function down()
	{
		Schema::table('workhours', function(Blueprint $table) {
			$table->dropForeign('workhours_restaurant_id_foreign');
		});
		Schema::table('workhours', function(Blueprint $table) {
			$table->dropForeign('workhours_day_id_foreign');
		});
	}
}

Je préfère ne pas utiliser la cascade pour les modifications et suppressions, il faut donc dans ce cas les gérer par le code.

J’ai aussi prévu la population pour avoir quelques restaurants et aussi les jours :

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        for ($i = 1; $i < 8; $i++) {
            DB::table('restaurants')->insert(['name' => 'Restaurant ' . $i]);
        }            

        DB::table('days')->insert([
            ['name' => 'Lundi' ],
            ['name' => 'Mardi' ],
            ['name' => 'Mercredi' ],
            ['name' => 'Jeudi' ],
            ['name' => 'Vendredi' ],
            ['name' => 'Samedi' ],
            ['name' => 'Dimanche' ],
        ]);

    }
}

Les modèles

On a donc deux modèles : Day et Restaurant.

Voici le code du premier :

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Day extends Model {

	public function restaurants()
	{
		return $this->belongsToMany('App\Restaurant', 'workhours')->withPivot('id', 'start_time', 'end_time');
	}

}

Et pour Restaurant :

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Restaurant extends Model {

	public function days()
	{
		return $this->belongsToMany('App\Day', 'workhours')->withPivot('start_time', 'end_time');
	}

}

Rien que du classique dans ce code !

Routes et contrôleur

Comme j’ai prévu un contrôleur de ressource au niveau des routes c’est tout simple :

Route::group(['middleware' => ['web']], function () {
	Route::resource('restaurant', 'RestaurantController', ['except' => ['create', 'store']]);
});

Je n’ai pas traité le cas de la création d’un restaurant parce que c’est pratiquement identique à la modification.

Voici le contrôleur :

<?php namespace App\Http\Controllers;

use App\Restaurant;
use App\Day;
use App\Http\Requests\RestaurantUpdateRequest;

class RestaurantController extends Controller {

  /**
   * Display a listing of the resource.
   *
   * @return Response
   */
  public function index()
  {
    $restaurants = Restaurant::paginate(5);

    return view('restaurants.index', compact('restaurants'));    
  }

  /**
   * Display the specified resource.
   *
   * @param  int  $id
   * @return Response
   */
  public function show($id)
  {
    $days = $this->getDaysWithRestaurants($id);
    $restaurant = $this->getRestaurantById($id);

    return view('restaurants.show', compact('days', 'restaurant'));
  }

  /**
   * Show the form for editing the specified resource.
   *
   * @param  int  $id
   * @return Response
   */
  public function edit($id)
  {
    $days = $this->getDaysWithRestaurants($id);
    $restaurant = $this->getRestaurantById($id);
    $index = 1;

    return view('restaurants.edit', compact('days','restaurant', 'index'));    
  }

  /**
   * Update the specified resource in storage.
   *
   * @param  RestaurantUpdateRequest $restaurantUpdateRequest
   * @param  int  $id
   * @return Response
   */
  public function update(RestaurantUpdateRequest $restaurantUpdateRequest, $id)
  {
    // Mise à jour du nom
    $restaurant = Restaurant::find($id);
    $restaurant->name = $restaurantUpdateRequest->name;
    $restaurant->save();

    // Balayage et mise à jour des plages
    $starts = $restaurantUpdateRequest->all()['start'];
    $ends = $restaurantUpdateRequest->all()['end'];    
    $restaurant->days()->detach();
    foreach ($starts as $key => $array){
      foreach ($array as $k => $value) {
        $restaurant->days()->attach([$key => ['start_time' => $value, 'end_time' => $ends[$key][$k]]]);
      }
    }

    return response()->json();    
  }

  /**
   * Remove the specified resource from storage.
   *
   * @param  int  $id
   * @return Response
   */
  public function destroy($id)
  {
    $restaurant = $this->getRestaurantById($id);
    // Détachement des plages horaires
    $restaurant->days()->detach();
    // Suppression du restaurant
    $restaurant->delete();   

    return back(); 
  }

  protected function getDaysWithRestaurants($id) 
  {
    return Day::with(['restaurants' => function ($query) use($id) {
                $query->where('restaurants.id', $id);
            }])->get();
  }

  protected function getRestaurantById($id)
  {
    return Restaurant::find($id);
  }
  
}

?>

Je n’ai pas prévu de repository pour simplifier et avoir ainsi tout le code d’un seul coup d’oeil.

La validation

Pour la validation j’ai prévu une requête de formulaire :

<?php

namespace App\Http\Requests;

class RestaurantUpdateRequest extends Request
{

	/**
	 * Get the validation rules that apply to the request.
	 *
	 * @return array
	 */
	public function rules()
	{
		$rules = [
			'name' => 'required|max:255',
			'start.*' => 'date',
			'end.*' => 'date'
		];

		return $rules;
	}

	/**
	 * Determine if the user is authorized to make this request.
	 *
	 * @return bool
	 */
	public function authorize()
	{
		return true;
	}

}

C’est loin d’être parfait parce que je me contente de vérifier qu’on a un format date pour les heures. d’autre part je ne vérifie pas s’il y a une cohérence entre les heures de début et les heures de fin…

Les vues

C’est au niveau des vues que le travail le plus délicat se passe. On en a 4 :

img77

Le template

Le template est classique :

<!DOCTYPE html>
<html lang="fr">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Gestion de restaurants</title>

    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
    @yield('css')
    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>
  <body>

    @yield('content')    

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script>
    @yield('scripts')
  </body>
</html>

La vue index

Cette vue est chargée d’afficher la liste des restaurants et les boutons de commande :

@extends('template')

@section('content')
	<div class="container">
		<div class="row col-md-offset-3 col-md-6">
			<h1>Restaurants</h1>
			<div class="panel panel-primary">
				<div class="panel-heading">
					<h3 class="panel-title">Liste des restaurants</h3>
				</div>
				<table class="table">
					<thead>
						<tr>
							<th>#</th>
							<th>Nom</th>
							<th></th>
							<th></th>
							<th></th>
						</tr>
					</thead>
					<tbody>
						@foreach ($restaurants as $restaurant)
							<tr>
								<td>{{ $restaurant->id }}</td>
								<td class="text-primary"><strong>{{ $restaurant->name }}</strong></td>
								<td>{!! link_to_route('restaurant.show', 'Voir', [$restaurant->id], ['class' => 'btn btn-success btn-block']) !!}</td>
								<td>{!! link_to_route('restaurant.edit', 'Modifier', [$restaurant->id], ['class' => 'btn btn-warning btn-block']) !!}</td>
								<td>
									{!! Form::open(['method' => 'DELETE', 'route' => ['restaurant.destroy', $restaurant->id]]) !!}
										{!! Form::submit('Supprimer', ['class' => 'btn btn-danger btn-block', 'onclick' => 'return confirm(\'Vraiment supprimer ce restaurant ?\')']) !!}
									{!! Form::close() !!}
								</td>
							</tr>
						@endforeach
					</tbody>
				</table>
			</div>
			{!! $restaurants->links() !!}
		</div>
	</div>
@endsection

img78

La vue show

Cette vue affiche le nom du restaurant et ses jours et heures d’ouverture :

@extends('template')

@section('content')
	<div class="container">
		<div class="row col-md-offset-3 col-md-6">
			<h1>Fiche de restaurant</h1>
			<div class="panel panel-primary">
				<div class="panel-heading">
					<h3 class="panel-title">Nom du restaurant</h3>
				</div>
				<div class="panel-body">
					{!! $restaurant->name !!}
				</div>
			</div>
			<div class="panel panel-primary">
				<div class="panel-heading">
					<h3 class="panel-title">Heures d'ouverture</h3>
				</div>
				<div class="panel-body">
					@foreach($days as $day)
						@if($day->restaurants->count() > 0)
							<strong>{{ $day->name }} :</strong><br> 
							<ul>
							@foreach($day->restaurants as $restaurant)
								<li>{{ $restaurant->pivot->start_time }} à {{ $restaurant->pivot->end_time }}</li>
							@endforeach
							</ul>
						@endif
					@endforeach
				</div>
			</div>
		</div>
	</div>
@endsection

img79La vue edit

C’est là qu’il y a le plus de travail parce qu’il faut gérer dynamiquement les plages horaires :

@extends('template')

@section('css')
	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/css/bootstrap-datetimepicker.min.css">
@endsection

@section('content')
	<div class="container">
	   	<div class="row">
			<h1 class="text-center">Modification d'un restaurant</h1>
			<hr>
			{!! Form::model($restaurant, ['route' => ['restaurant.update', $restaurant->id], 'method' => 'put', 'class' => 'form-horizontal']) !!}
				<div class="form-group">
					{!! Form::label('nom', 'Nom :', ['class' => 'col-sm-3 control-label']); !!}
					<div class="col-sm-9">
						{!! Form::text('name', null, ['class' => 'form-control']) !!}
						<small class="help-block"></small>
					</div>
				</div>
				<div class="alert alert-danger hidden" role="alert">Il y a une erreur de saisie de plage horaire, veuillez vérifier !
					<button type="button" class="close" data-dismiss="alert" aria-label="Close">
					  <span aria-hidden="true">×</span>
					</button>
				</div>
				@foreach($days as $day)
					<div class="panel panel-primary">
						<div class="panel-heading">
							<h3 class="panel-title">
								{{ $day->name }}							
								<button type="button" id="{{ $day->id }}" class="btn btn-info btn-xs pull-right add_plage">Ajouter une plage horaire</button>
							</h3>
						</div>
						<div class="panel-body">
							@foreach($day->restaurants as $restaurant)
								<div class="ligne">
									<div class="row form-group">
										<div class="col-sm-10"> 
											<label for="{{ 'start' . $index }}" class="col-sm-4 control-label">Heure de début :</label>
							                <div class="col-sm-8 input-group date">
							                	<input class="form-control" name="{{ 'start[' . $day->id . '][]' }}" id ="{{ 'start_' . $index++ }}" type="text" value="{{ $restaurant->pivot->start_time }}">
							                    <span class="input-group-addon">
							                        <span class="glyphicon glyphicon-time"></span>
							                    </span>	
							                </div>
							            </div>
							        </div>
						            <div class="row form-group">
							            <div class="col-sm-10"> 
							            	<label for="{{ 'end_' . $index }}" class="col-sm-4 control-label">Heure de fin :</label>
							                <div class="col-sm-8 input-group date">
							                	<input class="form-control" name="{{ 'end[' . $day->id . '][]' }}" id ="{{ 'end_' . $index++ }}" type="text" value="{{ $restaurant->pivot->end_time }}">
							                    <span class="input-group-addon">
							                        <span class="glyphicon glyphicon-time"></span>
							                    </span>
							                </div>
							            </div>								            
							            <div class="col-sm-2">
											<button type="button" class="btn btn-danger">Supprimer</button>
							            </div>	
							        </div>	
							    </div>						
					        @endforeach
						</div>
					</div>
		        @endforeach
		    	{!! Form::submit('Envoyer', ['class' => 'btn btn-primary']) !!}
			{!! Form::close() !!}
		</div>
	</div>
@endsection

@section('scripts')
	<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.12.0/moment-with-locales.min.js"></script>
 	<script src="https://cdn.jsdelivr.net/bootstrap.datetimepicker/4.17.37/js/bootstrap-datetimepicker.min.js"></script>
    <script>
        $(function () {
        	// Initialisation des DateTimePicker
            $('.date').datetimepicker({ locale: 'fr', format: 'LT' });

            // Initialisation index pour étiquettes
            var index = {{ $index }};

			// Suppression d'une ligne de réponse (utilisation de "on" pour gérer les boutons créés dynamiquement)
			$(document).on('click', '.btn-danger', function(){
				$(this).parents('.ligne').remove();	
			});

			// Ajout d'une ligne de plage horaire
			$('.add_plage').click(function() {
				var html = '<div class="ligne">\n'
				+ '<div class="row form-group">\n'
				+ '<div class="col-sm-10">\n' 
				+ '<label for="start' + index++ + '" class="col-sm-4 control-label">Heure de début :</label>\n'
				+ '<div class="col-sm-8 input-group date">\n'
				+ '<input class="form-control" name="start[' + $(this).attr("id") + '][]" id ="' + index++ + '" type="text">\n'
				+ '<span class="input-group-addon"><span class="glyphicon glyphicon-time"></span></span>\n'
				+ '</div></div></div>\n'
				+ '<div class="row form-group">\n'
				+ '<div class="col-sm-10">\n' 
				+ '<label for="end' + index++ + '" class="col-sm-4 control-label">Heure de fin :</label>\n'
				+ '<div class="col-sm-8 input-group date">\n'
				+ '<input class="form-control" name="end[' + $(this).attr("id") + '][]" id ="' + index++ + '" type="text">\n'
				+ '<span class="input-group-addon"><span class="glyphicon glyphicon-time"></span></span>\n'
				+ '</div></div>\n'
				+ '<div class="col-sm-2"><button type="button" class="btn btn-danger">Supprimer</button></div></div>\n'
				+ '</div>\n';
				$(this).parents('.panel').find('.panel-body').append(html);	
				$('.date').datetimepicker({ locale: 'fr', format: 'LT' });
			});

			// Soumission 
			$(document).on('submit', 'form', function(e) {  
				e.preventDefault();
				$.ajax({
					method: $(this).attr('method'),
					url: $(this).attr('action'),
					data: $(this).serialize(),
					dataType: "json"
				})
				.done(function(data) {
					window.location.href = '{!! url('restaurant') !!}';
				})
				.fail(function(data) {
					var obj = data.responseJSON;
					// Nettoyage préliminaire					
					$('.help-block').text('');
					$('.form-group').removeClass('has-error');	
					$('.alert').addClass('hidden');					
					// Balayage de l'objet
					$.each(data.responseJSON, function (key, value) {
						// Traitement du nom
						if(key == 'name') {
							$('.help-block').eq(0).text(value);
							$('.form-group').eq(0).addClass('has-error');							
						}
						// Traitement des erreurs des plages horaires
						else {
							$('.alert').removeClass('hidden');								
						}
					});
				});
			});
        });
    </script>
@endsection

J’ai utilisé Bootstrap DatePicker pour la saisie des horaires:

img81

D’autre par toute la gestion locale est faite avec jQuery puisqu’on en dispose déjà.

img80

Pour chaque jour de la semaine on peut mettre autant de plages horaires qu’on veut (ou aucune). Il y a un bouton Supprimer pour enlever une plage et un bouton Ajouter une plage horaire pour en créer une.

Evidemment la soumission se fait en Ajax pour pouvoir gérer les erreurs de saisie.

Je n’ai pas détaillé la validation des horaires parce que ça serait vraiment laborieux, je me suis contenté d’un message global.

Conclusion

C’est un bon cas d’école avec une gestion côté client intéressante. Il serait instructif de voir une application complète de ce genre de gestion de plages horaires.

  1. blast

    Bonjour et merci pour cet article très intéressant 🙂

    Un problème à signaler cependant avec l’archive zip, qui du coup est inutilisable : l’arborescence est absente tandis que chaque nom de fichier est préfixé du chemin relatif qui lui est propre. Tout est posé à plat à la racine du projet. Snif !

    • Author bestmomo

      Bonjour,

      Je viens de vérifier le ZIP est pour moi il est bon, ça doit être un souci avec le décompresseur utilisé.

      Cordialement

      • blast

        Bonjour, et Merci 🙂

        En fait je suis sous Linux. J’ai retenté en ligne commande cette fois et j’ai le message « warning : restaurants.zip appears to use backslashes as path separators ».

        Plus qu’à l’extraire dans une VM. Si quelqu’un connait une autre astuce …

Laisser un commentaire