九保すこひ@フリーランスエンジニア|累計300万PVのブログ運営中
さてさて、この間地元ニュースであるジムが閉店するという話を知りました。実はこのジムの予約システム開発に参加したことがあるので、少し寂しい気持ちになりました。
とはいえ、当時はCakePHP
すら話題になっていなかった大昔なので、「今開発するとしたら全然労力が違うんだろうな」とも感じました。(ガラケー3キャリア対応で泣かされました😫)
そして、今回はLaravel
を使って「部屋の予約システム」を作ってみようと思うのですが、せっかく作るからには何かもう1つ特徴をつけたいなと考えていると、あるアイデアが思い浮かびました。
部屋ごとに「時間枠」を設定できる
機能です。
例えば、以下のようなものです。
- Aという部屋は「30分刻み」で予約できる
- Bという部屋は「20分刻み」で予約できる
- Cという部屋は「1時間分刻み」で予約できる
つまり、部屋ごとに予約できる条件が違っていても対応できるようにします。
そこで❗
今回は、Laravel
で「時間枠が設定できる部屋予約システム」を作ってみることにします。
ぜひ何かの参考になりましたら嬉しいです。😊✨
「PCまわりの掃除には
スポンジを使ってます」
開発環境: Laravel 8.x
目次
前提として
今回は予約にログインが必須のシステムをつくります。そのため、ログイン機能がインストールされ、テストユーザーがusers
テーブルに登録されていることが前提です。
もしまだの方は以下を参考にしてみてください。
📝 参考ページ
モデル&マイグレーションをつくる
では、まずはモデル&マイグレーションのDBまわりからつくっていきます。
以下のコマンドを実行してください。
php artisan make:model Room -m
php artisan make:model Reservation -m
すると、モデル&マイグレーション・ファイルが一気に作成されるので、中身を以下のように変更します。
database/migrations/****_**_**_******_create_rooms_table.php
// 省略 public function up() { Schema::create('rooms', function (Blueprint $table) { $table->id(); $table->string('name')->comment('部屋名'); $table->unsignedInteger('time_steps')->comment('時間枠'); $table->timestamps(); }); }
database/migrations/****_**_**_******_create_reservations_table.php
// 省略 public function up() { Schema::create('reservations', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('user_id')->comment('ユーザーID'); $table->unsignedBigInteger('room_id')->comment('部屋ID'); $table->dateTime('starts_at')->comment('開始日時'); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users'); $table->foreign('room_id')->references('id')->on('rooms'); }); }
app/Models/Room.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Room extends Model { use HasFactory; protected $appends = ['time_step_values']; // Accessor public function getTimeStepValuesAttribute() { // 1時間の間に予約できる「分」を取得する $time_step_values = []; $count = 60 / $this->time_steps; for($i = 0 ; $i < $count ; $i++) { $time_step_values[] = $this->time_steps * $i; } return $time_step_values; } }
ここで追加したgetTimeStepValuesAttribute()
は、Laravel
のAccessor
と呼ばれる便利機能で、1時間の間に予約できる「分」が簡単に取得できるようにしています。
例えば、以下の例を見てください。
$room = \App\Models\Room::first(); dd($room->time_step_values); // 15分刻みの場合: [0, 15, 30, 45]
このように、配列で予約開始が可能な「分」が取得できるようになります。
なお、$appends
にこのtime_step_values
をセットしているのは、「常にこの値を取得したいから」です。
つまり、$appends
をセットすると、必ずこの値がついてくるようになります。
$room = \App\Models\Room::first(); dd($room->toArray()); // 👈 $appends をセットしていると、この中に「分」の配列が入ってくる
テストデータをつくる
次に今回テストで予約できるようにする「部屋」データをつくっていきます。
以下のコマンドを実行してください。
php artisan make:seed RoomSeeder
すると、ファイルが作成されるので中身を次のようにします。
database/seeders/RoomSeeder.php
<?php namespace Database\Seeders; use App\Models\Room; use Illuminate\Database\Seeder; class RoomSeeder extends Seeder { // 省略 public function run() { $time_steps = [15, 30, 60]; foreach ($time_steps as $index => $time_step) { $no = $index + 1; $room = new Room(); $room->name = '部屋'. $no; $room->time_steps = $time_steps[$index]; $room->save(); } } }
では、Seeder
ファイルは作成するだけでは実行できませんので、Laravel
側に登録します。
public function run() { // 省略 $this->call(RoomSeeder::class); }
それでは、以下のコマンドを実行してデータベースを再構築してみましょう。
php artisan migrate:fresh --seed
実際のテーブルはこうなりました。
コントローラーをつくる
続いてコントローラーをつくります。
以下のコマンドを実行してください。
php artisan make:controller ReservationController
すると、ファイルが作成されるので、中身を次のようにします。
<?php namespace App\Http\Controllers; use App\Models\Reservation; use App\Models\Room; use Illuminate\Http\Request; class ReservationController extends Controller { public function index() { $rooms = Room::select('id', 'name', 'time_steps')->get(); return view('reservation.index')->with([ 'rooms' => $rooms ]); } public function store(Request $request) { // TODO: バリデーションは省略しています $reservation = new Reservation(); $reservation->user_id = auth()->id(); $reservation->room_id = $request->room_id; $reservation->starts_at = $request->start_at; $result = $reservation->save(); return ['result' => $result]; } public function reservation_list(Request $request) { $reservations = Reservation::select('id', 'room_id', 'starts_at') ->whereDate('starts_at', $request->date) ->get(); return [ 'reservations' => $reservations ]; } }
では、この中でやっていることをメソッドごとにご紹介します。
index()
ここは、実際にブラウザからアクセスされるページでビューを読み込み、以下のような表示をします。
また、ビューに渡している$rooms
は、Vue.js
の変数にセットされてループで予約ボタンを描画することになります。
store()
Ajax
で予約を登録します。
今回はテストなのでバリデーションは省略していますが、ここではデータベースに予約内容を保存しているだけです。
※ なお、私が開発に使っている有料エディタphpstorm
では、TODO:
と書いておくと、自動的にまとめてくれる(のを最近発見した😂)のでこのように書いています。
reservation_list()
ここもAjax
でアクセスされる部分ですが、すでに予約済みのデータを取得するために用意しています。
また、取得する条件は「該当する年月日の全予約データ」になります。
※ ちなみに、whereYear()
, whereMonth(), whereDay(), whereTime()
を使うと「該当する年、月、日、時間」で絞り込みができます。Laravel
はやっぱり便利ですね👍
ビューをつくる
では次にビュー(HTML部分)を作っていきます。
以下のファイルを作成してください。
resources/views/reservation/index.blade.php
<html> <head> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css"> </head> <body> <div id="app"> <div class="p-3"> <div class="btn-group mb-4"> <button type="button" class="btn btn-outline-secondary" @click="moveDate(-1)"><</button> <button type="button" class="btn btn-outline-secondary" v-text="date"></button> <button type="button" class="btn btn-outline-secondary" @click="moveDate(1)">></button> </div> <div class="mb-5" v-for="room in rooms"> <h4> <span class="badge rounded-pill bg-info text-dark" v-text="room.name">Info</span> </h4> <div v-for="hours in allHours"> <div class="row"> <div class="col-auto pr-5 py-2"> <span v-text="getPaddedNumber(hours)"></span>時 </div> <div class="col-auto p-2" v-for="minutes in room.time_step_values"> <button class="btn btn-outline-dark" data-toggle="tooltip" :title="getTimeRange(hours, minutes, room.time_steps)" v-text="getPaddedNumber(minutes)" :disabled="!isReservationAvailable(room.id, hours, minutes)" @click="reserve(room.id, hours, minutes)"> </button> </div> </div> </div> </div> </div> </div> <script src="https://unpkg.com/vue@3.0.11/dist/vue.global.prod.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/luxon/1.27.0/luxon.min.js"></script> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.min.js"></script> <script> Vue.createApp({ data() { return { reservations: [], allHours: [], dt: null, rooms: @json($rooms) } }, methods: { getReservations() { const date = this.dt.toFormat('yyyy-MM-dd'); const url = '{{ route('reservation.reservation_list') }}?date='+ date; axios.get(url) .then(response => { this.reservations = response.data.reservations; }); }, getPaddedNumber(number) { return number.toString().padStart(2, '0'); // ゼロ埋めして必ず2ケタにする }, getTimeRange(hours, minutes, timeSteps) { const startDt = this.dt.set({ hours: hours, minutes: minutes }) const endDt = startDt.plus({ minutes: timeSteps }); return startDt.toFormat('H:mm') +' 〜 '+ endDt.toFormat('H:mm') +' のご予約'; }, isReservationAvailable(roomId, hours, minutes) { const today = luxon.DateTime.now().startOf('day'); if(this.dt < today) { return false; } const dt = this.dt.set({ hours: hours, minutes: minutes }) const startsAt = dt.toFormat('yyyy-MM-dd HH:mm:00'); const hasReservation = this.reservations.some(reservation => { // 指定した条件が存在してたら true return ( parseInt(reservation.room_id) === parseInt(roomId) && reservation.starts_at === startsAt ) }); return !hasReservation; }, moveDate(days) { this.dt = this.dt.plus({ days: days }); }, reserve(roomId, hours, minutes) { if(confirm('予約します。よろしいですか?')) { const dt = this.dt.set({ hours: hours, minutes: minutes }); const url = '{{ route('reservation.store') }}'; const params = { room_id: roomId, start_at: dt.toFormat('yyyy-MM-dd HH:mm') }; axios.post(url, params) .then(response => { if(response.data.result === true) { this.getReservations(); } }); } } }, computed: { date() { if(this.dt) { return this.dt.toFormat('yyyy/MM/dd'); } return ''; } }, watch: { dt() { this.getReservations(); } }, mounted() { for(let i = 0 ; i < 24 ; i++) { this.allHours.push(i); } this.dt = luxon.DateTime.now().startOf('day'); Vue.nextTick(() => { $('[data-toggle="tooltip"]').tooltip({ placement: 'right' }); }); } }).mount('#app'); </script> </body> </html>
今回この部分が一番コードが長くなってしまったのですが、日付管理をするluxon
(moment.js
の後継パッケージ)さえ理解していればそれほど難しい内容ではないと思います。
📝 参考ページ:【JavaScript】Luxon で日付の操作・全実例38件!
この中で一番重要なのが、「すでに予約済みの時間枠は操作できないようにする」ということです。
その役目を担っているのがisReservationAvailable()
で、この中では、Ajax
を通してreservation_list()
から取得した「予約済みデータ」と、それぞれの時間枠を比べ、もし予約データがある場合は、「disabled」を有効にしてボタン操作できないようにしています。
ルートをつくる
use App\Http\Controllers\ReservationController; // 省略 Route::prefix('reservation')->middleware('auth')->group(function(){ Route::get('/', [ReservationController::class, 'index'])->name('reservation.index'); Route::get('/reservation_list', [ReservationController::class, 'reservation_list'])->name('reservation.reservation_list'); Route::post('/', [ReservationController::class, 'store'])->name('reservation.store'); });
内容としては、コントローラーでつくった各メソッドにURLを対応させ、後で使いやすいようにname()
をつけているだけです。
テストしてみる
では、実際にテストしてみましょう❗
まず先にログインをしてから、「http://******/reservation/」にアクセスします。
すると・・・・・・
はい❗
テストデータとして作成した部屋1〜3に「時間枠ごとの」予約ボタンを表示されました。
なお、このボタンはマウスを合わせるとツールチップが表示されるようになっています。
では、このままクリックして、「0:45分〜1:00」で予約をしてみましょう。
すると・・・・・・
確認ダイアログが表示されるのでOKをクリックします。
さて、どうなるでしょうか・・・・・・
はい❗
ちょっと分かりにくいかもしれませんが、予約ができたのでボタンの色が薄くなりました。
これは、disabled
が効いている状態なので、マウスを合わせてもツールチップは表示されませんし、クリックしても何も起こりません。
成功です😊✨
では、データベースの方も確認しておきましょう。
はい❗キチンと予約データが登録されています。
全て成功です😊✨
企業様へのご提案
今回の技術を使えば以下のようなことが実現できます。
- 部屋ごとに時間枠を指定できるので、いろいろな条件に対応できる予約システムをつくることができます。
- 部屋だけに限らず、トレーナーさんや講師さんの予約にも利用可能です。
- 今回は24時間予約が可能でしたが、これも部屋ごとに条件を指定するようカスタマイズすることができます。
- さらに拡張して、「●日前までならキャンセル可」のような機能をつけることもできます。
もしこういった内容にご興味があるようでしたら、ぜひお問い合わせからご連絡ください。どうぞよろしくお願いいたします。m(_ _)m
おわりに
とうことで、今回はLaravel
で「時間枠が指定できる」予約システムを作ってみました。
なお、今回CSSフレームワークには2021.5.5
にリリースされたばかりのBootstrap 5
を使う予定にしていました。
しかし、使ってみた結果、(とても残念なのですが)上方向のツールチップだけうまく表示できない状態だったので、急遽Bootstrap 4
を使うことにしました。(4
では問題なかったので、「うーん、なぜだろう😥」という感じです)
ちなみに、Bootstrap 5
はjQuery
依存がなくなった結果、少し書き方にクセがでてきたなという印象でした。
とはいえ、テスト的につくる場合はまだまだTailwindCSS
よりBootstrap
が楽なので、今後に期待したいところです👍
ぜひ、皆さんもBootstrap 5
も含めてチャレンジしてみてくださいね。
ではでは〜❗
「カナダで知った
ヒマワリの種スナック
日本で買えるようになってました👍」