【fullcalendar】Laravelとfullcalendar(v4)を使って予約管理システムを作った話【Laravel】


<b>仮想案件</b>
家庭教師予約システムをつくる

<b>仕様概要</b>
・家庭教師派遣会社は先生（学生バイト達）をAdmin管理画面からスケジューリング
・会員（生徒）は任意の先生を管理画面から予約
・派遣会社の画面と会員の画面はそれぞれ別の認証経路（マルチAuth）
・UIはGoogleカレンダー風を希望

<b>前提</b>
・fullcalendarメインの記事になります
・Laravelの基本を理解している方向けです
・非同期通信の基本も理解している方向けです
・データベース設計の基本も理解している方向けです
要はfullcalendarにポイントを置いてます。

<h1>目次</h1>
1. 完成イメージ
2. LaravelのマルチAuth対応
3. 管理画面の作成
4. fullcalendarの組み込み
5. ビジネスロジックの実装
6. 補足

<h1>1.完成イメージ</h1>

<b>家庭教師派遣会社が使用するAdmin管理画面(週表示)</b>
カレンダー左横の先生の箱をカレンダーにドロップしてイベントを作成
![スクリーンショット_2020-03-21_11_08_44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151314/7eca1dce-9408-ed94-3348-cd2c857f5b1a.png)

<b>会員(生徒)が使用する予約画面(週表示)</b>
任意のイベント(先生)をクリックして予約処理をする
![スクリーンショット_2020-03-21_11_36_20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151314/052fe115-24a0-3ee3-33cc-fb9502f6abea.png)

会員(生徒)が使用する予約画面(月表示)
![スクリーンショット_2020-03-21_11_40_35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151314/be8b90d6-2e64-89e0-ba92-c5fca440be06.png)

<h1>2.LaravelのマルチAuth対応</h1>
派遣会社と会員（生徒）はそれぞれのログイン認証を経ることになるのでその対応です。
既存のguardとproviderに加え、admin用のguardとproviderを作成します。
このあたりは良記事が既に存在するのでそちらを参照してください
（いきなり参照ですいません；）

[Laravel6 備忘録 −ユーザー認証(Auth)−](https://qiita.com/sny_shinya/items/6a94c16fbb66cd9f06d7)
[【Laravel】マルチログイン（ユーザーと管理者）機能](https://qiita.com/tkek321/items/1e8fa58e548017d244fd)

<h1>3.管理画面の作成</h1>
左にメニュー、右にコンテンツエリアがあるオーソドックスなレイアウトです。
組み方はこちらを参照してください。
（またまた参照ですいません…しかも手前味噌で；）

[【Laravel】デフォルトの管理画面に左メニューをサクッと設置
](https://qiita.com/fromage-blanc/items/717a54584829f4c7728c)

<h1>4.fullcalendarの組み込み</h1>
まずadminから。今回はadmin用のテンプレにてCDNで読み込んでます。
（月表示、週表示、デイリー、リスト、日本語対応）
fullcalendar V3だとapp.jsとぶつかるらしいですが、V4では特に問題ありませんでした。

※admin用の外枠テンプレートをadmin_app.blade.phpとして作った場合

```html:/resources/views/layouts/admin_app.blade.php
    <link href='https://unpkg.com/@fullcalendar/core@4.3.1/main.min.css' rel='stylesheet' />
    <link href='https://unpkg.com/@fullcalendar/daygrid@4.3.0/main.min.css' rel='stylesheet' />
    <link href='https://unpkg.com/@fullcalendar/timegrid@4.3.0/main.min.css' rel='stylesheet' />
    <link href='https://unpkg.com/@fullcalendar/list@4.3.0/main.min.css' rel='stylesheet' />
    <script src='https://unpkg.com/@fullcalendar/core@4.3.1/main.min.js'></script>
    <script src='https://unpkg.com/@fullcalendar/interaction@4.3.0/main.min.js'></script>
    <script src='https://unpkg.com/@fullcalendar/daygrid@4.3.0/main.min.js'></script>
    <script src='https://unpkg.com/@fullcalendar/timegrid@4.3.0/main.min.js'></script>
    <script src='https://unpkg.com/@fullcalendar/list@4.3.0/main.min.js'></script>
    <script src='https://unpkg.com/@fullcalendar/core/locales/ja'></script>
```

ビューファイルを作成します。
今回は/resources/views/admin/schedule/calendar.blade.phpで作成。
html部分はこんな感じです。

```html:calendar.blade.php

<div class="container">
    <div class="row justify-content-center">

        <!-- left -->
        @include('admin.menu')

        <div class="col-md-10">
            <div class="card">
                <div class="card-header"><i class="fas fa-id-card"></i> カレンダー共有</div>
                <div class="card-body">
                @if (session('status'))
                    <div class="alert alert-success" role="alert">
                    {{ session('status') }}
                    </div>
                @endif

                    <div id='external-events'>
                        <p><strong>先生</strong></p>

                            @foreach ( $data as $d )
                　　　　        <div class="fc-event">{{ $d->teacher_name }}<span style="opacity:0;">:{{ $d->id }}</span></div>
                            @endforeach

                        <p style="display:none;">
                　　　　    <input type='checkbox' id='drop-remove' />
                           <label for='drop-remove'>remove after drop</label>
                        </p>
                    </div>

                    <div id='calendar-container'>
                       <div id='calendar'></div>
                   </div>
               </div><!-- end card-body -->
           </div><!-- end card -->
        </div>
    </div>
</div>
```

`fc-event`div要素（カレンダー左に列挙されてるドラッグ＆ドロップ可能要素）には今回先生の名前が入るので、Controllerでフェッチしたデータをまわして動的に配置してます。同div内直後の`<span style="opacity:0;">:{{ $d->id }}</span>`はeventReceive時に先生IDをイベントに登録するための苦肉の策です；不可視化してコロンの後に入れといて取り出してます；

```javascript
var arr = info.event.title.split(':');
info.event.setExtendedProp('teacher_id', arr[1]);
```

続いて同ファイル内のjs部分です。fullcalendarのインスタンス作成とプロパティやらコールバックを定義します。各コールバックにビジネスロジックを記述した関数を置いていく感じになると思います。eventRenderでクリック間隔にてシングルクリックとダブルクリックを判定してるのがなんかあれですが…
`events:{url}`はデータソースの取得元です。通常だとDBから取得したデータをJSONにしてエコーする処理になるでしょう。ちなみにメソッドは`GET`限定です。fullcalendar側で見えている日時範囲を`start`と`end`という名前で返してくれるので、パフォーマンスを考慮してSQLのwhere句で指定してあげるのが良いです。

```javascript:calendar.blade.php

document.addEventListener('DOMContentLoaded', function() {
   var Calendar = FullCalendar.Calendar;
   var Draggable = FullCalendarInteraction.Draggable;

   var containerEl = document.getElementById('external-events');
   var calendarEl = document.getElementById('calendar');
   var checkbox = document.getElementById('drop-remove');

   // initialize the external events
    new Draggable(containerEl, {
        itemSelector: '.fc-event',
            eventData: function(eventEl) {
                return {
                    title: eventEl.innerText
                };
            }
    });

    // initialize the calendar
    var calendar = new Calendar(calendarEl, {
        plugins: [ 'interaction', 'dayGrid', 'timeGrid','list' ],
        header: {
          left: 'prev,next today',
          center: 'title',
          right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
        },
        allDaySlot: false,
        forceEventDuration : true,
        eventColor: 'lavender',
        defaultTimedEventDuration: '01:00',
        defaultView: 'timeGridWeek',
        slotDuration: '00:10:00',
        minTime : '10:00',
        maxTime : '22:10',
        locale : 'jaLocale',
        editable: true,
        selectable: true,
        allDaySlot: false,
        droppable: true, // this allows things to be dropped onto the calendar
            buttonText: {
                today:'今日',
                month:'月',
                week: '週',
                day:  '日',
                list: 'リスト'
            },

        events:'/admin/events/source',

        select: function (info) {
            // カレンダーセルクリック、範囲指定された時のコールバック
            console.log('select');
        },
        
        eventReceive: function(info) {
            // イベントがexternal-eventからドロップされた時のコールバック
            console.log('eventReceive');
        },

        eventDrop: function(info) {
            // イベントがドロップされた時のコールバック
            console.log('eventDrop');
        },

        eventResize: function(info) {
            // イベントがリサイズ（引っ張ったり縮めたり）された時のコールバック
            console.log('eventResize');
        },

        eventRender: function (info) {
          //wired listener to handle click counts instead of event type
          info.el.addEventListener('click', function() {
            clickCnt++;
            if (clickCnt === 1) {
                oneClickTimer = setTimeout(function() {
                    clickCnt = 0;

                    // SINGLE CLICK
                    console.log('single click');

                }, 400);
            } else if (clickCnt === 2) {
                clearTimeout(oneClickTimer);
                clickCnt = 0;

                // DOUBLE CLICK
                console.log('double click');
            }
          });
        }
    })

  calendar.render();
});
```

cf. events:に指定したURLのサンプル

```php:/admin/events/source.php

// テストとしてイベントを３つ作成してみる

$data = [];

$ev1 = ['id'=>'1','teacher_id'=>'18','title'=>'event1','start'=>'2020-03-17T10:00:00','color'=>'lightpink'];
$ev2 = ['id'=>'2','teacher_id'=>'20','title'=>'event2','start'=>'2020-03-18T10:30:00','color'=>'lightgreen'];
$ev3 = ['id'=>'3','teacher_id'=>'35','title'=>'event3','start'=>'2020-03-18T10:50:00','color'=>'yellow'];

array_push($data,$ev1,$ev2,$ev3);

echo json_encode($data);

```

`teacher_id`はfullcalendarのイベントモデルではデフォルトのプロパティではないですが、未定義のプロパティは`extendedProps`の下に生やしてくれます。複数系なのでタイポ注意です。値の更新は`setExtendedProp`です。

```javascript
// infoはコールバック時に渡されるオブジェクト

// アクセス
info.event.extendedProps.teacher_id
// 値変更
info.event.setExtendedProp('teacher_id',40);

```

<h1>5.ビジネスロジックの実装</h1>
ここまでくればあとはゴリゴリとビジネスロジックを書いていくだけです。
非同期にてDB更新してイベントの見た目を変更するような処理がメインになるでしょう。

```javascript:calendar.blade.php
// eventReceive時に走る処理のサンプル 

// 略
    eventReceive: function(info) {
        Create(info);
    },
// 略

const Create = (info) => {

    var dt = new Date();
    info.event.setExtendedProp('identifier',dt.getTime());

    // csrf。Laravelお約束
    $.ajaxSetup({
        headers: {
            'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
        }
    });

    $.ajax({
        type: 'post',
        data: {
                'identifier': info.event.extendedProps.identifier,
                'teacher_id': info.event.extendedProps.teacher_id,
                'start': info.event.start,
                'end': info.event.end
        },
        datatype: 'json',
            url: '/admin/update' /* identifierをキーに登録or更新 */
        })
        .done(function(data){
            json = JSON.parse(data);
            if ( json['result'] == 'success' ) {
                // サーバサイドにて設定された背景色に変更
                info.event.setProp('color',json['color']);
            }
        })
        .fail(function(data){
            alert('error');
        });
}

```

```php:sample(/admin/update)
use App\Event; // Eventモデル使用

$event = Event::firstOrNew(['identifier'=>$request->identifier]);
$event->teacher_id = $request->teacher_id;
$event->start = date('Y-m-d H:i',strtotime(strstr($request->start,'GMT',true)));
$event->end = date('Y-m-d H:i',strtotime(strstr($request->end,'GMT',true)));

DB::transaction(function() use ($event) {
    try {
        $event->save();
    } catch (\Exception $e) {
        // エラー処理
    }
});

echo json_encode(array('result'=>'success','color'=>'yellow'));

```

生徒側のカレンダーも基本adminと一緒でよいのですが認証経路が違うので処理内容が一緒でも`events:{url}`の部分はメソッドをコピペして移植するなどの変更が必要です。それとイベントのドロップ＆ドラッグのUIは必要ないので、該当箇所を削除します（html部分のexternal-events要素も忘れずに）。

```javascript:calendar.blade.php

document.addEventListener('DOMContentLoaded', function() {

   // 略

   /* ここから
   var Draggable = FullCalendarInteraction.Draggable;

   var containerEl = document.getElementById('external-events');
   var calendarEl = document.getElementById('calendar');
   var checkbox = document.getElementById('drop-remove');

   // initialize the external events
    new Draggable(containerEl, {
        itemSelector: '.fc-event',
            eventData: function(eventEl) {
                return {
                    title: eventEl.innerText
                };
            }
    });
    ここまで削除 */

    // 略

        events:'/user/events/source', /* /admin/events/sourceではNG */

```

<h1>6.補足</h1>
<b>イベントのクリアと再取得</b>
週送りや月を送るとfullcalendarはイベントデータを都度イベントソースからフェッチしてくるのでイベントをクリアしてやらないと重複して表示される現象にみまわれました。以下はその対応です。キレイじゃない方法ですが載せておきます；

```javascript:calendar.blade.php
// gCalendarはcalendarの参照

$(document).on('click','.fc-next-button,.fc-prev-button,.fc-dayGridMonth-button,.fc-timeGridWeek-button,.fc-timeGridDay-button,.fc-listWeek-button', function () {

    events = gCalendar.getEvents();
    var len = events.length;
    for (var i = 0; i < len; i++) {
            events[i].remove();
    }

    eventSources = gCalendar.getEventSources();
    var len = eventSources.length;
    for (var i = 0; i < len; i++) {
        eventSources[i].remove();
    }
    gCalendar.addEventSource('/admin/events/source');
    gCalendar.refetchEvents();
});
```

<b>イベントIDについて</b>
イベントモデルではデフォルトで`id`が用意されていますが、クライアント処理でsetPropによる値の変更はできないようです。スタックオーバーフローではバグではないのかという議論がされていましたが、おそらく仕様だと思われます。
fullcalendarがイベント作成時に自動で採番してくれるわけではないのでextnededPropsでしのぎました。

```javascript:calendar.blade.php

var dt = new Date();
info.event.setExtendedProp('identifier',dt.getTime());

```

<b>動的なカレンダーの設定変更</b>
「月次ビューの時はselectable(選択操作の可否)をfalseにしたい」等、動的に設定を切り替えたい場面が多々あると思います。そんな時はfullcalendarの持つ`setOption`メソッドを使用します。

```javascript
    /* view type
        dayGridMonth 月次
        timeGridWeek 週次
        timeGridDay  日次
        listWeek     リスト
    */

    // 月次ビューの時は選択操作を無効にする
    if ( gCalendar.view.type == 'dayGridMonth' ) {
        gCalendar.setOption('selectable', false);
    } else {
        gCalendar.setOption('selectable', true);
    }
```

<b>最後に</b>

ここで紹介したのはほんの一部です。
いろいろできるので公式のぞいてみてください。

[fullcalendar公式](https://fullcalendar.io/)
[External Event Dragging](https://fullcalendar.io/docs/external-dragging)