anTech

News

FullCalendarを使わずカレンダーUIを自作した理由と実装 — ドラッグ&ドロップのシフト管理システムを作る【vol.3/全4回】

開発ブログ
自作カレンダーUIのグリッド構造図。縦=時間スロット(既定15分・設定で変更可)、横=スタッフ列

この連載は、訪問看護向けのシフト管理システム「YORISOI」を自作した記録です(全4回)。vol.3は技術編のハイライト——vol.1で紹介したドラッグ&ドロップのシフト表を、外部ライブラリなし・素のJavaScript約1,260行でどう作ったかの話です。データ設計はvol.2をどうぞ。

こんな方向けの記事

  • JavaScriptでカレンダーUIを自作してみたいエンジニア
  • FullCalendarのライセンスや、カレンダーライブラリの選定で迷っている方
  • ドラッグアンドドロップの実装(スナップ・ゴースト表示)の具体例を見たい方
  • シフト表・予約管理・工程表など「時間×リソース(人・設備・案件)」の画面を業務システムに入れたい方

FullCalendarを使わなかった理由 — リソースビューは商用利用で有償ライセンス

JavaScriptのカレンダーライブラリといえばFullCalendarが定番で、本体はMITライセンスで無料です。ただし今回どうしても必要だった「スタッフごとの列に予定を並べる」リソースビューは、商用利用では有償のPremium(Scheduler)ライセンスの対象です(執筆時点で開発者1名あたり$480/年。アップデートを受け続けるには更新が必要)。

YORISOIは「月額固定の低価格で提供する」ことがコンセプトなので、ライブラリのライセンス費用が毎年のコストとして提供価格に乗る構造は避けたい。そして要件を整理すると、必要なのは「15分刻みのグリッドに、色分けした予定を置いて、ドラッグで動かせる」こと——月表示もタイムゾーンも要らない。必要な機能だけなら自作できると判断しました。

結果としてできたのが、外部依存ゼロ・素のJavaScript 2ファイル(合計約1,260行)の自作カレンダーです。npmパッケージもCDNもビルド工程もなし。JSは<script>タグ2本だけ(+コンポーネント用のCSSが約290行)です。

自作カレンダーUIの全体像

自作カレンダーUIのグリッド構造図。縦=時間スロット(既定15分・設定で変更可)、横=スタッフ列、予定は絶対配置でtopとheightを計算、スナップはMath.round
構造は「縦=時間スロット、横=リソース(スタッフ)列」。予定は座標計算して絶対配置するだけ

設計はシンプルで、divのグリッドの上に、予定ブロックを絶対配置で重ねるだけです。

  • CalendarGridクラス1つ(1,107行)+日付ユーティリティ(155行)。テーブルタグは使わずdiv+flexbox
  • 5つのビュー(今日・スタッフごと・日付ごと・個人の日/週)を、汎用レンダラ1本で描画。ビューの違いは「列の並べ方(日付が外側か、スタッフが外側か)」だけ
  • 各セルに data-datedata-resource-id を持たせておき、ドロップ位置からの逆引きに使う
  • ヘッダーと時刻列の固定はCSSの position: sticky に丸投げ。スクロール同期のJavaScriptは0行です

予定ブロックの配置は、時間をピクセルに変換する式が1つあれば足ります。

// 「09:30の予定」をどの高さに描くか
topSlots = (開始時刻の分 - 表示開始08:00の分) / slotDuration(既定15分)
top      = topSlots * slotHeight(24px)
height   = 所要スロット数 * slotHeight - 2  // 2pxはブロック間の隙間
left     = 時刻列の幅 + 列番号 * 列幅 + 2

呼び出し側のAPIはライブラリ風にしてあります。データの取得はコールバック注入——取得処理を関数として外から渡しておく方式——なので、カレンダー本体はURLもフレームワークも知りません。

// app/Views/shift/index.php — 初期化はこれだけ
calendarGrid = new CalendarGrid('calendar', {
    slotDuration: <?= $slotDuration ?>, // 時間の区切り(組織設定から取得・既定15分)
    slotMinTime: '08:00',
    slotMaxTime: '22:00',
    slotHeight: 24,
    editable: isAdmin,         // 権限で編集可否を切り替え
    initialView: isAdmin ? 'resourceDay' : 'day',
    viewButtons: viewButtons,  // 出すビューを宣言的に指定
    eventsFn: async (start, end) => {   // データはコールバックで注入
        const data = await apiFetch('/shift/events?start=' + start + '&end=' + end);
        return data.events || [];
    },
});

ドラッグ&ドロップ実装 — 閾値5px・ゴースト・スナップ

ドラッグ&ドロップ(D&D)は、HTML5のDrag and Drop APIではなく、mousedown / mousemove / mouseupの素朴な組み合わせで実装しています。理由は見た目と挙動を完全にコントロールしたいから。ポイントは3つです。

① クリックとドラッグの区別。mousedownしただけではドラッグ扱いにせず、5px動いた瞬間にドラッグ開始とみなします。これで「クリック=編集ダイアログ」「ドラッグ=移動」が、同じ予定ブロックの上で衝突せずに共存できます。

// public/js/calendar-grid.js — ドラッグ開始の閾値チェック
if (!isDragging) {
    if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
    isDragging = true;

    // ゴースト作成(本体はその場に残し、半透明の分身を動かす)
    ghostEl = eventEl.cloneNode(true);
    ghostEl.classList.add('cg-event-ghost');
    body.appendChild(ghostEl);
    eventEl.classList.add('cg-event-dragging');
}

② ドラッグ中はゴーストだけを動かす。本体をその場に残して分身を動かすと、「元の位置」と「行き先」が同時に見えるので、ユーザーが迷いません。

③ スナップはMath.roundするだけ。ゴーストの座標をスロット高・列幅で丸めれば、スロット単位(既定15分)・列単位に吸い付きます。

// Y: スロット単位スナップ(既定15分のスロットに吸着)
newTop = Math.round(newTop / slotHeight) * slotHeight;

// X: 列単位スナップ(スタッフ列に吸着)
const colIdx = Math.round((newLeft - timeColumnWidth) / colWidth);
newLeft = timeColumnWidth + colIdx * colWidth + 2;

ドロップ確定時は、着地した列のセルが持つ data-date / data-resource-id を逆引きして新しい日時と担当を決めます。この仕組みのおかげで、「時間の移動」「日付をまたぐ移動」「担当スタッフの付け替え」が同じ1つのコードで動きます。ドロップした瞬間に保存まで走ります——vol.1のデモ動画でシフトがスッと動いていた、あの挙動です。

予定の作成・変更もドラッグで完結させる

空いているセルをドラッグすると範囲選択になり、離した位置から開始・終了時刻を計算して新規作成ダイアログを開きます。ドラッグした範囲がそのまま「11:00〜12:00」のように初期値に入るので、時刻の入力がほぼ不要になります。

空きセルをドラッグ → 選択範囲の時刻が入った状態でダイアログが開く → 保存(1.25倍速・画面はサンプルデータ)

予定の長さ変更は、ブロック下端のハンドルをドラッグ。こちらもスロット単位でスナップし、離した時点で終了時刻を再計算して自動保存します。

下端をドラッグして30分延長 → 自動保存 → 編集ダイアログを開いて変更後の終了時刻を確認(1.25倍速・画面はサンプルデータ)

自作してわかった「割り切り」ポイント

正直に書くと、このカレンダーはFullCalendarの代替ではありません。「リソースビュー相当の、自分たちが使う機能だけ」を作ったものです。割り切った点も晒しておきます。

  • 月表示・リスト表示・終日/複数日イベントはなし(シフト管理に不要だったため)
  • マウス操作のみ(スマホは閲覧用途と割り切り、タッチでのD&Dは未対応)
  • 表示は日本語・週の開始は月曜固定(国内の業務システム前提)
  • 描画はビュー切替のたびに全再構築するシンプル方式(差分更新なし)

逆に言うと、この割り切りができるのが自作の強みです。汎用ライブラリは「誰の要件にも合う」ように作られているぶん重く、カスタマイズと戦うことになりがち。要件が絞れているなら、必要な機能だけの自作は現実的な選択肢です。

この構造は、予約システムや工程表アプリにもそのまま使える

同じカレンダー構造の応用例3つ。訪問看護のシフト表、設備・部屋の予約管理、工程表・案件管理はどれも時間×リソースの2軸グリッド
列と軸のラベルを変えるだけで、シフト表・予約管理・工程表が同じ構造で表現できる

ここまで「訪問看護のシフト表」として説明してきましたが、カレンダー本体のAPIや変数名に「スタッフ」や「訪問」という言葉は出てきません。扱うのは「リソース」と「イベント」だけ。列に何を並べるかはデータ次第です。

  • 予約システムを自作したい — 列を「会議室」「スタジオ」「コート」にすれば設備予約の画面になります。LINEやGoogleフォームの無料予約で物足りなくなった事業者向け
  • 工程表をエクセル(Excel)以外で作りたい — 縦軸を時刻から日付に読み替え、列を「案件」や「工程」にすれば、ドラッグで組み替えられる工程表・案件管理ボードに。変わるのは軸の単位だけで、構造は同じです
  • 業種を問わないシフト表 — 時間帯・列幅はすべて設定値。さらに時間の区切り(スナップ間隔)自体を、システムの設定画面から5〜60分で変更できるように改修しました。飲食店や教室の30分刻みにも、画面からの切り替えだけで対応できます

実はこの「区切り間隔」、もともとはコードに15分と直書きしていました。この記事を書きながら「15分は訪問看護の要件であって、カレンダーの都合ではないな」と気づき、組織設定のマスタ値(5〜60分)としてシフト画面が読み込む形に改修。設定を変えると、グリッドの行数もドラッグのスナップ幅も丸ごと切り替わります。

YORISOIの組織設定画面。シフトの時間区切りを5〜60分から選択できるセレクトボックス
組織設定に「シフトの時間区切り」を追加(例: 30分を選択)
時間区切りを30分に変更した後のシフト画面。グリッドが30分刻みになり1画面に表示できる時間帯が広がる
設定を30分にすると、同じ画面が30分刻みのグリッドに(画面はサンプルデータ)

データ取得(eventsFn / resourcesFn)と見た目(renderEventContent)はコールバックで差し替えられる設計なので、業務が変わってもカレンダー本体には手を入れません。実際、YORISOIの画面はこの仕組みの上に「利用者名とサービス種別を表示する」薄い層を載せているだけです。

カレンダーUI開発・費用のよくある質問

Q. 予約システムやシフト管理システムの開発費用はどれくらい?
要件次第ですが、カレンダーUI部分はこの記事の仕組みを流用できるため、ゼロから作るより大きく抑えられます。「何をドラッグで動かしたいのか」「列に何を並べたいのか」が決まっていれば、具体的なお見積りが出せます。

Q. 既存の予約システムやシフト管理SaaSではダメ?
汎用SaaSで要件が満たせるなら、それが一番安く済みます。ただ「15分刻みで動かしたい」「うちの業務の単位で列を並べたい」のような構造レベルの要望は、既製品では吸収しきれないことが多い。この連載のYORISOIのように、業務の形に合わせて画面から設計できるのが自作・受託開発の価値です。

次回予告

vol.4(最終回)は、デモ環境を安全に公開する仕組みの話です。読み取り専用モード(DEMO_MODE)の設計、サンプルデータの作り方、そして実際に触れるYORISOIの公開デモをお披露目する予定です。

このシステムについて

この連載で作っている訪問看護シフト管理システムは「YORISOI」として提供しています。機能の詳細・デモのご案内はこちらから。

YORISOIの紹介ページを見る →

お仕事のご依頼について

普段は中小企業様向けのWeb制作・システム開発を中心に活動しています。「うちの予約管理もこの画面にできる?」「Excelの工程表をやめてドラッグで組みたい」など、お気軽にご相談ください。Excelからのデータ移行もご協力いたします。

開発の相談をしてみる →

Profile

anTech / アンテク

地域の中小企業様向けにシステム開発・Web制作を行うフリーランスエンジニア。「ちょうどいい」技術で、御社のビジネスをサポートします。

埼玉県川越市拠点 — 川越市・日高市・狭山市・鶴ヶ島市を中心に埼玉県全域対応