仮想案件
家庭教師予約システムをつくる
仕様概要
・家庭教師派遣会社は先生(学生バイト達)をAdmin管理画面からスケジューリング
・会員(生徒)は任意の先生を管理画面から予約
・派遣会社の画面と会員の画面はそれぞれ別の認証経路(マルチAuth)
・UIはGoogleカレンダー風を希望
前提
・fullcalendarメインの記事になります
・Laravelの基本を理解している方向けです
・非同期通信の基本も理解している方向けです
・データベース設計の基本も理解している方向けです
要はfullcalendarにポイントを置いてます。
目次
- 完成イメージ
- LaravelのマルチAuth対応
- 管理画面の作成
- fullcalendarの組み込み
- ビジネスロジックの実装
- 補足
1.完成イメージ
家庭教師派遣会社が使用するAdmin管理画面(週表示)
カレンダー左横の先生の箱をカレンダーにドロップしてイベントを作成
会員(生徒)が使用する予約画面(週表示)
任意のイベント(先生)をクリックして予約処理をする
2.LaravelのマルチAuth対応
派遣会社と会員(生徒)はそれぞれのログイン認証を経ることになるのでその対応です。
既存のguardとproviderに加え、admin用のguardとproviderを作成します。
このあたりは良記事が既に存在するのでそちらを参照してください
(いきなり参照ですいません;)
Laravel6 備忘録 −ユーザー認証(Auth)−
【Laravel】マルチログイン(ユーザーと管理者)機能
3.管理画面の作成
左にメニュー、右にコンテンツエリアがあるオーソドックスなレイアウトです。
組み方はこちらを参照してください。
(またまた参照ですいません…しかも手前味噌で;)
【Laravel】デフォルトの管理画面に左メニューをサクッと設置
4.fullcalendarの組み込み
まずadminから。今回はadmin用のテンプレにてCDNで読み込んでます。
(月表示、週表示、デイリー、リスト、日本語対応)
fullcalendar V3だとapp.jsとぶつかるらしいですが、V4では特に問題ありませんでした。
※admin用の外枠テンプレートを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部分はこんな感じです。
<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をイベントに登録するための苦肉の策です;不可視化してコロンの後に入れといて取り出してます;
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句で指定してあげるのが良いです。
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のサンプル
// テストとしてイベントを3つ作成してみる
$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
です。
// infoはコールバック時に渡されるオブジェクト
// アクセス
info.event.extendedProps.teacher_id
// 値変更
info.event.setExtendedProp('teacher_id',40);
5.ビジネスロジックの実装
ここまでくればあとはゴリゴリとビジネスロジックを書いていくだけです。
非同期にてDB更新してイベントの見た目を変更するような処理がメインになるでしょう。
// 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');
});
}
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要素も忘れずに)。
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 */
6.補足
イベントのクリアと再取得
週送りや月を送るとfullcalendarはイベントデータを都度イベントソースからフェッチしてくるのでイベントをクリアしてやらないと重複して表示される現象にみまわれました。以下はその対応です。キレイじゃない方法ですが載せておきます;
// 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();
});
イベントIDについて
イベントモデルではデフォルトでid
が用意されていますが、クライアント処理でsetPropによる値の変更はできないようです。スタックオーバーフローではバグではないのかという議論がされていましたが、おそらく仕様だと思われます。
fullcalendarがイベント作成時に自動で採番してくれるわけではないのでextnededPropsでしのぎました。
var dt = new Date();
info.event.setExtendedProp('identifier',dt.getTime());
動的なカレンダーの設定変更
「月次ビューの時はselectable(選択操作の可否)をfalseにしたい」等、動的に設定を切り替えたい場面が多々あると思います。そんな時はfullcalendarの持つsetOption
メソッドを使用します。
/* view type
dayGridMonth 月次
timeGridWeek 週次
timeGridDay 日次
listWeek リスト
*/
// 月次ビューの時は選択操作を無効にする
if ( gCalendar.view.type == 'dayGridMonth' ) {
gCalendar.setOption('selectable', false);
} else {
gCalendar.setOption('selectable', true);
}
最後に
ここで紹介したのはほんの一部です。
いろいろできるので公式のぞいてみてください。