Laravel で「時間枠」が設定できる部屋予約システムをつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、この間地元ニュースであるジムが閉店するという話を知りました。実はこのジムの予約システム開発に参加したことがあるので、少し寂しい気持ちになりました。

とはいえ、当時は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()は、LaravelAccessorと呼ばれる便利機能で、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)">&lt;</button>
            <button type="button" class="btn btn-outline-secondary" v-text="date"></button>
            <button type="button" class="btn btn-outline-secondary" @click="moveDate(1)">&gt;</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>

今回この部分が一番コードが長くなってしまったのですが、日付管理をするluxonmoment.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

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

とうことで、今回はLaravelで「時間枠が指定できる」予約システムを作ってみました。

なお、今回CSSフレームワークには2021.5.5にリリースされたばかりのBootstrap 5を使う予定にしていました。

しかし、使ってみた結果、(とても残念なのですが)上方向のツールチップだけうまく表示できない状態だったので、急遽Bootstrap 4を使うことにしました。(4では問題なかったので、「うーん、なぜだろう😥」という感じです)

ちなみに、Bootstrap 5jQuery依存がなくなった結果、少し書き方にクセがでてきたなという印象でした。

とはいえ、テスト的につくる場合はまだまだTailwindCSSよりBootstrapが楽なので、今後に期待したいところです👍

ぜひ、皆さんもBootstrap 5も含めてチャレンジしてみてくださいね。

ではでは〜❗

「カナダで知った
ヒマワリの種スナック
日本で買えるようになってました👍」

このエントリーをはてなブックマークに追加       follow us in feedly