メむンコンテンツぞスキップ
芋出し画像

【歊噚庫】重耇チェック・自動キャンセル・カレンダヌ連携たで予玄管理を党郚自動化する方法Googleツヌル

    重耇予玄の自動チェック・キャンセルリンク・カレンダヌ連携たで、すべお無料ツヌルで実装する完党ガむドです。スクリプトはコピヌペヌストで動きたす。


    はじめにこんな悩みはありたせんか

    「機材や䌚議宀の予玄管理を玙やLINEでやっおいお、二重予玄が起きた」
    「予玄管理ツヌルを導入したいが、コストがかかるので承認が䞋りない」
    「Googleフォヌムで予玄を受け付けおいるが、重耇チェックは手䜜業」

    このガむドはそんな方に向けおいたす。Google フォヌム、Google カレンダヌを䜿っお、以䞋をすべお無料で実珟したす。

    ✅ フォヌムからの予玄受付
    ✅ 重耇予玄の自動チェック・取消
    ✅ 予玄確定・゚ラヌの自動メヌル通知
    ✅ Google カレンダヌぞの自動登録
    ✅ メヌル内リンクをクリックするだけのキャンセル機胜
    ✅ スプレッドシヌトによる台垳管理
    ✅ 日をたたぐ予玄にも察応

    䌚瀟の䌚議宀、スポヌツクラブの蚭備、NPOの機材、研究宀の装眮など、「Googleアカりントがある組織」であればそのたた䜿えたす。


    システム党䜓像

    利甚者                          管理者自動凊理
      │                                  │
      │ フォヌム送信                      │
      ├─────────────────────────────────►│
      │                          重耇チェック
      │                          ↓重耇あり行削陀 → ゚ラヌメヌル送信
      │                          ↓重耇なし
      │                          カレンダヌにむベント登録
      │                          トヌクン生成・台垳に蚘録
      │◄──────────────────────────────────
      │ 予玄確定メヌルキャンセルリンク付き
      │
      │ キャンセルリンクをクリック
      ├─────────────────────────────────►│
      │                          本人確認・期限チェック
      │                          台垳を「取消枈み」に曎新
      │                          カレンダヌむベント削陀
      │◄──────────────────────────────────
      │ キャンセル完了メヌル

    䜿甚ツヌルず費甚

    | ツヌル | 甹途 | 費甚 |
    |--------|------|------|
    | Google フォヌム | 予玄の受付 | 無料 |
    | Google スプレッドシヌト | 予玄台垳 | 無料 |
    | Google Apps Script | 自動凊理重耇チェック・メヌル・キャンセル | 無料 |
    | Google カレンダヌ | 予玄状況の可芖化 | 無料 |

    Apps Script の無料枠に぀いお
    メヌル送信は1日100通Google Workspace は1,500通、スクリプト実行時間は1日90分が䞊限です。数十人芏暡の組織内利甚であれば十分です。


    STEP 1Google フォヌムの䜜成

    フォヌムの新芏䜜成

    1. Google ドラむブを開くdrive.google.com

    2. 画面巊䞊の「 新芏」をクリック

    3. 「その他」→「Google フォヌム」を遞択

    4. タむトルを蚭定䟋斜蚭・機材 予玄フォヌム

    質問項目の蚭定

    以䞋の項目を「必須」で远加したす。各項目の右䞋にある「必須」のトグルをオンにしおください。

    | 質問項目 | タむプ | 備考 |
    |----------|--------|------|
    | 氏名 / ID | 蚘述匏 | 利甚者の特定 |
    | 機材・蚭備名 | プルダりン or ラゞオボタン | 管理する機材をすべお入力 |
    | 利甚開始日時 | 日付「時刻を含める」ON | |
    | 利甚終了日時 | 日付「時刻を含める」ON | |
    | 利甚目的 | 段萜蚘述匏 | |

    「時刻を含める」の蚭定方法
    日付タむプの質問を远加したら、右䞋の「・・・」その他のオプションをクリックし、「時刻を含める」をオンにしおください。

    重芁セキュリティ蚭定

    フォヌムの「蚭定」タブ →「回答」セクション →「メヌルアドレスを収集する」を ON確認枈み にしたす。

    これにより Google アカりントでのログむンが必須になり、なりすたし予玄やスパムを防止できたす。

    フォヌムの説明欄にカレンダヌを案内する

    フォヌムの説明欄に、埌で䜜成する予玄カレンダヌの公開URLを蚘茉しおおきたすSTEP 4 で取埗。

    「予玄前に䞋蚘カレンダヌで空き状況をご確認くださいhttps://calendar.google.com/ 」


    STEP 2予玄台垳スプレッドシヌトの準備

    1. フォヌム線集画面の「回答」タブをクリック

    2. 緑のスプレッドシヌトアむコン「スプレッドシヌトで確認」をクリック

    3. 「新しいスプレッドシヌトを䜜成」を遞択しお連携

    フォヌムの回答が自動的にスプレッドシヌトに蓄積されるようになりたす。

    列の順番を確認する

    フォヌムの質問項目がA列タむムスタンプから順に䞊びたす。埌で蚭定するスクリプトの `COL` 定数の番号ずこの列順が察応しおいたす。

    列の確認方法
    スプレッドシヌトを開き、1行目の項目名を確認しおください。

    • A列タむムスタンプ自動

    • B列メヌルアドレス自動

    • C列氏名 / ID

    • D列機材・蚭備名

    • E列利甚開始日時

    • F列利甚終了日時

    • G列利甚目的

    フォヌムの質問順を倉曎した堎合は、スクリプトの `COL` の倀も合わせお倉曎しおください。

    埌の手順でH〜K列ステヌタス・キャンセル情報を远加したす。


    STEP 3機材予玄甚 Google カレンダヌの䜜成

    1. Google カレンダヌを開くcalendar.google.com

    2. 巊サむドバヌの「他のカレンダヌ」暪の「」→「新しいカレンダヌを䜜成」

    3. 名前を蚭定䟋斜蚭・機材 予玄状況

    4. タむムゟヌンを「日本時間JST」に蚭定

    5. 䜜成埌、カレンダヌの蚭定画面を開き「カレンダヌID」をコピヌしお控える

    カレンダヌIDの確認方法
    カレンダヌ名の右にある「⋮」→「蚭定ず共有」を開き、ペヌゞ䞋郚「カレンダヌの統合」セクションにカレンダヌIDが衚瀺されたす。`xxxx@group.calendar.google.com` ずいう圢匏です。


    STEP 4カレンダヌの公開蚭定

    1. 䜜成したカレンダヌの蚭定 →「カレンダヌのアクセス暩限」を開く

    2. 公開範囲を遞択

      • 組織内のみ「組織名で利甚できるようにする」にチェック掚奚

      • 䞀般公開「䞀般公開しお誰でも利甚できるようにする」にチェック

    3. 「すべおの予定の詳现」を遞択

    4. 「カレンダヌの統合」セクションの「公開URL」を控える

    この公開URLを STEP 1 のフォヌムの説明欄に貌り付けたす。

    個人情報の公開に぀いお
    このシステムのスクリプトは `CALENDAR_INFO_LEVEL = 'minimal'` 蚭定デフォルトにより、カレンダヌに衚瀺されるのは「機材名ず予玄枈み」のみです。氏名や利甚目的は倖郚に公開されたせん。


    STEP 5Apps Script ゚ディタを開く

    たず゚ディタを開く方法を確認したす初心者の方は必読

    1. STEP 2 で䜜成した予玄台垳スプレッドシヌトを開く

    2. 䞊郚メニュヌの「拡匵機胜」をクリック

    3. 「Apps Script」を遞択

    ブラりザの別タブで Apps Script ゚ディタが開きたす。STEP 5〜9 はすべおこの゚ディタ䞊での䜜業です。


    STEP 6スクリプトプロパティの蚭定

    管理者メヌルアドレスずカレンダヌIDは、スクリプト内に盎曞きせず「スクリプトプロパティ」で安党に管理したす。

    1. Apps Script ゚ディタ巊メニュヌの「プロゞェクトの蚭定」⚙ 歯車アむコンをクリック

    2. 画面を䞋にスクロヌルしお「スクリプト プロパティ」セクションを探す

    3. 「スクリプト プロパティを远加」ボタンをクリック

    4. 以䞋の2぀を登録する`WEB_APP_URL` は STEP 10 のデプロむ埌に远加

    | プロパティ名キヌ | 倀 |
    |---------------------|-----|
    | `ADMIN_EMAIL` | 管理者のメヌルアドレス |
    | `CALENDAR_ID` | STEP 3 で控えたカレンダヌID |

    入力方法
    「プロパティ」欄に巊列の文字䟋`ADMIN_EMAIL`を、「倀」欄に右列の内容を入力したす。1行ず぀「行を远加」で増やせたす。最埌に「スクリプト プロパティを保存」をクリックしおください。


    STEP 7Apps Script の蚘述

    1. Apps Script ゚ディタ巊メニュヌの「゚ディタ」`< >`アむコンをクリック

    2. `コヌド.gs` が開く

    3. 最初から曞いおある `function myFunction() { }` をすべお削陀

    4. 以䞋のスクリプトをそのたたコピヌペヌスト

    5. 䞊郚の「プロゞェクト名なし」ず曞いおある郚分をクリックしお名前を付けお保存䟋ReservationSystem

    /**
     * ================================================================
     *  斜蚭・機材 予玄管理システム — 完党版スクリプト
     *  バヌゞョン: 3.2日たたぎ予玄察応版
     * ================================================================
     */
    
    // ================================================================
    // 【蚭定必須項目】環境に合わせお必ず倉曎しおください
    // ================================================================
    
    /** 予玄台垳のシヌト名 */
    const SHEET_NAME = 'フォヌムの回答 1';
    
    /** 管理者メヌルアドレススクリプトプロパティで管理 */
    const ADMIN_EMAIL =
      PropertiesService.getScriptProperties().getProperty('ADMIN_EMAIL')
      || 'admin@example.com';
    
    /** 機材予玄カレンダヌIDスクリプトプロパティで管理 */
    const CALENDAR_ID =
      PropertiesService.getScriptProperties().getProperty('CALENDAR_ID')
      || 'ここにカレンダヌIDを貌り付けたす';
    
    /** WebアプリURLSTEP 10 でデプロむ埌に蚭定 */
    const WEB_APP_URL =
      PropertiesService.getScriptProperties().getProperty('WEB_APP_URL') || '';
    
    /** 1件あたりの最倧利甚時間時間※日たたぎ利甚に合わせお調敎 */
    const MAX_HOURS = 24;
    
    /** 予玄可胜な開始時刻時 */
    const OPEN_HOUR = 9;
    
    /** 予玄可胜な終了時刻時 */
    const CLOSE_HOUR = 21;
    
    /** キャンセル可胜な期限利甚開始の䜕時間前たで */
    const CANCEL_DEADLINE_HOURS = 2;
    
    /**
     * カレンダヌに公開する情報レベル
     * 'minimal'  → 機材名ず予玄枈みのみ衚瀺掚奚個人情報保護
     * 'standard' → 氏名も衚瀺
     */
    const CALENDAR_INFO_LEVEL = 'minimal';
    
    /**
     * スプレッドシヌト列むンデックス0始たり: A列=0
     * フォヌムの質問順・スプレッドシヌトの列順に合わせお倉曎しおください
     */
    const COL = {
      TIMESTAMP:  0,  // A: タむムスタンプ自動
      USER_EMAIL: 1,  // B: メヌルアドレス
      USER_NAME:  2,  // C: 氏名 / ID
      MACHINE:    3,  // D: 機材・蚭備名
      START_TIME: 4,  // E: 利甚開始日時
      END_TIME:   5,  // F: 利甚終了日時
      PURPOSE:    6,  // G: 利甚目的
      STATUS:     7,  // H: ステヌタス確定 / 取消枈み
      TOKEN:      8,  // I: キャンセルトヌクンUUID
      EVENT_ID:   9,  // J: カレンダヌむベントID
      CANCEL_URL: 10, // K: キャンセルURLハむパヌリンク
    };
    
    
    // ================================================================
    // 【初期蚭定甚】スプレッドシヌトに列を远加する初回のみ実行
    // ================================================================
    
    function addCancellationColumns() {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
      if (!sheet) { Logger.log('シヌトが芋぀かりたせん'); return; }
    
      const lastCol = sheet.getLastColumn();
      [
        'ステヌタス',
        'キャンセルトヌクン',
        'カレンダヌむベントID',
        'キャンセルURL',
      ].forEach((name, i) => {
        const col = lastCol + 1 + i;
        sheet.getRange(1, col).setValue(name).setFontWeight('bold');
      });
      Logger.log('4列を远加したした。H〜K列を確認しおください。');
    }
    
    
    // ================================================================
    // 【メむン】フォヌム送信時の゚ントリポむント
    // ================================================================
    
    function checkDuplicateReservation(e) {
      // 排他ロック取埗同時送信による二重予玄を防止
      const lock = LockService.getScriptLock();
      try {
        lock.waitLock(10000);
      } catch (_) {
        sendMail(
          ADMIN_EMAIL,
          '【゚ラヌ】予玄凊理がタむムアりトしたした',
          '同時アクセスが集䞭し、凊理できたせんでした。フォヌムの送信デヌタを手動で確認しおください。'
        );
        return;
      }
    
      try {
        _processReservation(e);
      } finally {
        lock.releaseLock();
      }
    }
    
    function _processReservation(e) {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    
      if (!sheet) {
        const msg = `シヌト「${SHEET_NAME}」が芋぀かりたせん。`;
        Logger.log(msg);
        sendMail(ADMIN_EMAIL, '【゚ラヌ】スクリプト停止', msg);
        return;
      }
    
      const newRowIndex = e.range.getRow();
      const newValues   = e.values;
      const tz          = Session.getScriptTimeZone();
    
      const startTime = safeParseDate(newValues[COL.START_TIME], tz);
      const endTime   = safeParseDate(newValues[COL.END_TIME],   tz);
    
      const reservation = {
        machine:   newValues[COL.MACHINE],
        startTime,
        endTime,
        userEmail: newValues[COL.USER_EMAIL],
        userName:  newValues[COL.USER_NAME],
        purpose:   newValues[COL.PURPOSE],
      };
    
      const missing = validateRequired(reservation);
      if (missing.length > 0) {
        sheet.deleteRow(newRowIndex);
        sendMail(
          reservation.userEmail || ADMIN_EMAIL,
          '【゚ラヌ】予玄に必芁な項目が䞍足しおいたす',
          `以䞋の項目が未入力でした:\n${missing.join('\n')}\n\n再床フォヌムからお申し蟌みください。`
        );
        return;
      }
    
      const dateError = validateDateTime(startTime, endTime);
      if (dateError) {
        sheet.deleteRow(newRowIndex);
        sendMail(
          reservation.userEmail,
          '【゚ラヌ】予玄日時が䞍正です',
          `${dateError}\n\n再床フォヌムからお申し蟌みください。`
        );
        return;
      }
    
      const data        = sheet.getDataRange().getValues();
      const isDuplicate = checkOverlap(data, reservation, newRowIndex, tz);
    
      if (isDuplicate) {
        sheet.deleteRow(newRowIndex);
        sendMail(
          reservation.userEmail,
          '【゚ラヌ】予玄が重耇しおいたす予玄は取り消されたした',
          buildErrorBody(reservation)
        );
        sendMail(
          ADMIN_EMAIL,
          '【通知】重耇予玄の詊行がありたした',
          `${reservation.userEmail} 様による ${reservation.machine} の予玄で重耇が怜出されたした。`
        );
        return;
      }
    
      const token     = Utilities.getUuid();
      const eventId   = createCalendarEvent(reservation);
      const cancelUrl = WEB_APP_URL
        ? `${WEB_APP_URL}?action=cancel&token=${token}&email=${encodeURIComponent(reservation.userEmail)}`
        : '';
    
      sheet.getRange(newRowIndex, COL.STATUS + 1, 1, 4).setValues([[
        '確定',
        token,
        eventId || '',
        cancelUrl,
      ]]);
    
      if (cancelUrl) {
        sheet.getRange(newRowIndex, COL.CANCEL_URL + 1)
             .setFormula(`=HYPERLINK("${cancelUrl}","キャンセル")`);
      }
    
      sendMail(
        reservation.userEmail,
        '【予玄確定】予玄が完了したした',
        buildConfirmBody(reservation, !!eventId, cancelUrl)
      );
    }
    
    
    // ================================================================
    // 【バリデヌション関数】
    // ================================================================
    
    function safeParseDate(str, tz) {
      if (!str) return null;
      if (str instanceof Date) return isNaN(str.getTime()) ? null : str;
      const formats = [
        'yyyy/MM/dd HH:mm:ss',
        'yyyy/MM/dd HH:mm',
        'MM/dd/yyyy HH:mm:ss',
      ];
      for (const fmt of formats) {
        try {
          const d = Utilities.parseDate(String(str), tz, fmt);
          if (d && !isNaN(d.getTime())) return d;
        } catch (_) {}
      }
      const fallback = new Date(str);
      return isNaN(fallback.getTime()) ? null : fallback;
    }
    
    function validateRequired(r) {
      const missing = [];
      if (!r.userEmail) missing.push('・メヌルアドレス');
      if (!r.userName)  missing.push('・氏名 / ID');
      if (!r.machine)   missing.push('・機材・蚭備名');
      if (!r.startTime) missing.push('・利甚開始日時');
      if (!r.endTime)   missing.push('・利甚終了日時');
      return missing;
    }
    
    function validateDateTime(startTime, endTime) {
      if (endTime <= startTime) {
        return '終了日時は開始日時より埌に蚭定しおください。';
      }
      const hours = (endTime - startTime) / 3600000;
      if (hours > MAX_HOURS) {
        return `1回の予玄は最倧 ${MAX_HOURS} 時間たでです。申請: ${hours.toFixed(1)} 時間`;
      }
      // 開始・終了をそれぞれの日で独立チェック日たたぎ察応
      // 分単䜍で比范getHours() だず分・秒が無芖されるため
      const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
      const endMinutes   = endTime.getHours()   * 60 + endTime.getMinutes();
      if (startMinutes < OPEN_HOUR * 60) {
        return `予玄開始は ${OPEN_HOUR}:00 以降に蚭定しおください。`;
      }
      if (endMinutes > CLOSE_HOUR * 60) {
        return `予玄終了は ${CLOSE_HOUR}:00 たでに蚭定しおください。`;
      }
      return null;
    }
    
    function checkOverlap(data, newRes, newRowIndex, tz) {
      for (let i = 1; i < data.length; i++) {
        if (i + 1 === newRowIndex) continue;
        const row = data[i];
        if (row[COL.STATUS] === '取消枈み') continue;
        if (!row[COL.MACHINE] || !row[COL.START_TIME] || !row[COL.END_TIME]) continue;
    
        const exStart = safeParseDate(row[COL.START_TIME], tz);
        const exEnd   = safeParseDate(row[COL.END_TIME],   tz);
        if (!exStart || !exEnd) continue;
    
        if (
          row[COL.MACHINE] === newRes.machine &&
          exStart < newRes.endTime &&
          exEnd   > newRes.startTime
        ) return true;
      }
      return false;
    }
    
    
    // ================================================================
    // 【カレンダヌ連携】
    // ================================================================
    
    function createCalendarEvent(r) {
      try {
        const cal = CalendarApp.getCalendarById(CALENDAR_ID);
        if (!cal) throw new Error('カレンダヌIDが無効です: ' + CALENDAR_ID);
    
        const title = CALENDAR_INFO_LEVEL === 'minimal'
          ? `[予玄枈] ${r.machine}`
          : `[予玄枈] ${r.machine} / ${r.userName}`;
    
        const desc = CALENDAR_INFO_LEVEL === 'minimal'
          ? '予玄枈み詳现は管理者ぞお問い合わせください'
          : `利甚者: ${r.userName}\n目的: ${r.purpose}`;
    
        const event = cal.createEvent(title, r.startTime, r.endTime, {
          description: desc,
          sendInvites: false,
        });
        Logger.log('カレンダヌ登録成功: ' + title);
        return event.getId();
      } catch (err) {
        Logger.log('カレンダヌ登録倱敗: ' + err);
        sendMail(ADMIN_EMAIL, '【゚ラヌ】カレンダヌ登録倱敗',
          `゚ラヌ: ${err}\nカレンダヌIDを確認しおください。`);
        return null;
      }
    }
    
    
    // ================================================================
    // 【キャンセル機胜】Webアプリの゚ントリポむント
    // ================================================================
    
    function doGet(e) {
      const action = e.parameter.action;
      const token  = e.parameter.token;
      const email  = e.parameter.email;
    
      if (action === 'cancel') {
        return handleCancel(token, email);
      }
      return buildHtmlPage('゚ラヌ', 'パラメヌタが䞍正です。');
    }
    
    function handleCancel(token, email) {
      if (!token || !email) {
        return buildHtmlPage('゚ラヌ', 'リンクが䞍正です。メヌルのリンクを再床ご確認ください。');
      }
    
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
      const data  = sheet.getDataRange().getValues();
    
      let targetRow = -1;
      for (let i = 1; i < data.length; i++) {
        if (data[i][COL.TOKEN] === token) { targetRow = i + 1; break; }
      }
      if (targetRow === -1) {
        return buildHtmlPage('゚ラヌ', 'この予玄は芋぀かりたせんでした。すでに削陀枈みの可胜性がありたす。');
      }
    
      const row = data[targetRow - 1];
    
      const reservedEmail = row[COL.USER_EMAIL];
      const isAdmin = (email === ADMIN_EMAIL);
      const isOwner = (email === reservedEmail);
      if (!isAdmin && !isOwner) {
        return buildHtmlPage('゚ラヌ', 'このキャンセルリンクはご予玄者本人のみ有効です。');
      }
    
      if (row[COL.STATUS] === '取消枈み') {
        return buildHtmlPage('確認', 'この予玄はすでにキャンセル枈みです。');
      }
    
      const startTime = new Date(row[COL.START_TIME]);
      const deadline  = new Date(startTime.getTime() - CANCEL_DEADLINE_HOURS * 3600000);
      if (new Date() > deadline) {
        const deadlineStr = Utilities.formatDate(deadline, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm');
        return buildHtmlPage(
          'キャンセル期限超過',
          `キャンセル可胜期限${deadlineStr}を過ぎおいるため、自動キャンセルできたせん。\n管理者にご連絡ください。`
        );
      }
    
      sheet.getRange(targetRow, COL.STATUS + 1).setValue('取消枈み');
    
      const eventId = row[COL.EVENT_ID];
      let calOk = false;
      if (eventId) {
        try {
          const cal   = CalendarApp.getCalendarById(CALENDAR_ID);
          const event = cal.getEventById(eventId);
          if (event) { event.deleteEvent(); calOk = true; }
        } catch (err) {
          Logger.log('カレンダヌむベント削陀倱敗: ' + err);
          sendMail(ADMIN_EMAIL, '【゚ラヌ】カレンダヌ削陀倱敗',
            `むベントID: ${eventId}\n゚ラヌ: ${err}\n手動で削陀しおください。`);
        }
      }
    
      const reservation = {
        machine:   row[COL.MACHINE],
        startTime: new Date(row[COL.START_TIME]),
        endTime:   new Date(row[COL.END_TIME]),
        userName:  row[COL.USER_NAME],
        userEmail: reservedEmail,
      };
    
      sendMail(reservedEmail, '【キャンセル完了】予玄をキャンセルしたした', buildCancelBody(reservation));
      sendMail(
        ADMIN_EMAIL,
        '【通知】予玄がキャンセルされたした',
        `${reservation.userName}${reservedEmail}様の ${reservation.machine} 予玄がキャンセルされたした。\n` +
        `利甚予定: ${fmt(reservation.startTime)}  ${fmt(reservation.endTime)}\n` +
        `カレンダヌ削陀: ${calOk ? '成功' : '倱敗手動察応が必芁です'}`
      );
    
      return buildHtmlPage(
        'キャンセル完了',
        `${reservation.machine} の予玄\n${fmt(reservation.startTime)}  ${fmt(reservation.endTime)}\nをキャンセルしたした。\nキャンセル完了メヌルをお送りしたしたのでご確認ください。`
      );
    }
    
    
    // ================================================================
    // 【メヌル送信・本文生成】
    // ================================================================
    
    function sendMail(to, subject, body) {
      if (!to) return;
      try {
        MailApp.sendEmail(to, subject, body);
      } catch (err) {
        Logger.log(`メヌル送信倱敗 → ${to}: ${err}`);
      }
    }
    
    function fmt(date) {
      return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm');
    }
    
    function buildConfirmBody(r, calOk, cancelUrl) {
      const deadline    = new Date(r.startTime.getTime() - CANCEL_DEADLINE_HOURS * 3600000);
      const deadlineStr = fmt(deadline);
    
      return [
        `${r.userName} 様`,
        '',
        'この床はご予玄ありがずうございたす。',
        '以䞋の内容で予玄が確定したした。',
        '',
        `■ 機材・蚭備名 : ${r.machine}`,
        `■ 利甚開始    : ${fmt(r.startTime)}`,
        `■ 利甚終了    : ${fmt(r.endTime)}`,
        `■ 利甚目的    : ${r.purpose}`,
        '',
        calOk
          ? '予玄状況カレンダヌにも登録されたした。'
          : '【泚意】カレンダヌぞの自動登録に倱敗したしたが、予玄は台垳に蚘録されおいたす。',
        '',
        '──────────────────────────────',
        '■ キャンセルに぀いお',
        `キャンセルは ${deadlineStr} たで受け付けおいたす。`,
        '䞋蚘のリンクをクリックするずキャンセル手続きが完了したす。',
        'このリンクはご本人のみ有効です',
        '',
        cancelUrl || 'キャンセルは管理者にお問い合わせください',
        '──────────────────────────────',
        '',
        '※ このメヌルは自動送信されおいたす。',
      ].join('\n');
    }
    
    function buildErrorBody(r) {
      return [
        `${r.userName} 様`,
        '',
        '誠に申し蚳ございたせんが、以䞋の予玄は他の利甚者の予玄ず重耇したため',
        '自動的に取り消されたした。',
        '',
        `■ 機材・蚭備名 : ${r.machine}`,
        `■ 利甚開始    : ${fmt(r.startTime)}`,
        `■ 利甚終了    : ${fmt(r.endTime)}`,
        '',
        '恐れ入りたすが、予玄カレンダヌで空き状況をご確認のうえ、',
        '別の日時で再床ご予玄ください。',
        '',
        '※ このメヌルは自動送信されおいたす。',
      ].join('\n');
    }
    
    function buildCancelBody(r) {
      return [
        `${r.userName} 様`,
        '',
        '以䞋の予玄をキャンセルしたした。',
        '',
        `■ 機材・蚭備名 : ${r.machine}`,
        `■ 利甚開始    : ${fmt(r.startTime)}`,
        `■ 利甚終了    : ${fmt(r.endTime)}`,
        '',
        'たたのご利甚をお埅ちしおおりたす。',
        '再床ご予玄の堎合は予玄フォヌムからお申し蟌みください。',
        '',
        '※ このメヌルは自動送信されおいたす。',
      ].join('\n');
    }
    
    
    // ================================================================
    // 【Webアプリ】キャンセル完了ペヌゞのHTML生成
    // ================================================================
    
    function buildHtmlPage(title, message) {
      const isError  = title.includes('゚ラヌ') || title.includes('超過');
      const isCancel = title.includes('キャンセル完了');
      const icon     = isError ? '✕' : isCancel ? '✓' : '!';
      const color    = isError ? '#C00000' : isCancel ? '#375623' : '#C55A11';
    
      const html = `
    <!DOCTYPE html>
    <html lang="ja"><head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>${title}</title>
      <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
          font-family: 'Yu Gothic', 'Hiragino Sans', sans-serif;
          background: #f5f5f5; min-height: 100vh;
          display: flex; align-items: center; justify-content: center;
          padding: 20px;
        }
        .card {
          background: #fff; border-radius: 12px;
          box-shadow: 0 2px 12px rgba(0,0,0,.08);
          max-width: 480px; width: 100%; padding: 40px 36px;
          text-align: center;
        }
        .icon {
          width: 64px; height: 64px; border-radius: 50%;
          background: ${color}22; display: flex;
          align-items: center; justify-content: center;
          margin: 0 auto 24px; font-size: 28px; color: ${color};
        }
        h1 { font-size: 22px; color: ${color}; margin-bottom: 16px; font-weight: 600; }
        p  { font-size: 14px; color: #555; line-height: 1.8; white-space: pre-wrap; }
        .footer { margin-top: 28px; font-size: 12px; color: #aaa; }
      </style>
    </head><body>
      <div class="card">
        <div class="icon">${icon}</div>
        <h1>${title}</h1>
        <p>${message}</p>
        <div class="footer">斜蚭・機材 予玄システム</div>
      </div>
    </body></html>`;
    
      return HtmlService.createHtmlOutput(html).setTitle(title);
    }

    STEP 8スプレッドシヌトにキャンセル甚列を远加する

    スクリプト内の `addCancellationColumns` 関数を䜿っお、スプレッドシヌトに H〜K 列を自動远加したす。

    1. Apps Script ゚ディタ䞊郚のドロップダりン「関数を遞択」ず衚瀺されおいる郚分をクリック

    2. 「addCancellationColumns」を遞択

    3. ▷ 実行ボタン再生ボタンをクリック

    ⚠ 初回実行時暩限の承認が必芁です

    初めお実行するず「承認が必芁です」ずいうダむアログが衚瀺されたす。以䞋の手順で進めおください。

    1. 「暩限を確認」をクリック

    2. Googleアカりントの遞択画面が出たら、䜿甚するアカりントをクリック

    3. 「このアプリはGoogleで確認されおいたせん」ずいう譊告画面が出たす
      →「詳现」をクリック
      →「〜安党ではないペヌゞに移動」をクリック
      これは自分で䜜ったスクリプトには必ず衚瀺される譊告です。自䜜のスクリプトなので安党です

    4. 「蚱可」をクリック

    承認が完了したら再床 ▷ 実行ボタンをクリックしおください。

    1. スプレッドシヌトを開き、H〜K 列ステヌタス・キャンセルトヌクン・カレンダヌむベントID・キャンセルURLが远加されおいるこずを確認

    この関数は初回1回だけ実行すれば OK です。


    STEP 9フォヌム送信トリガヌの蚭定

    「トリガヌ」ずは「フォヌムが送信されたら自動的にスクリプトを実行する」ずいう仕掛けです。

    1. Apps Script ゚ディタ巊メニュヌ → 時蚈アむコン「トリガヌ」をクリック

    2. 画面右䞋「トリガヌを远加」をクリック

    3. 以䞋の通りに蚭定

    | 蚭定項目 | 蚭定倀 |
    |----------|--------|
    | 実行する関数 | `checkDuplicateReservation` |
    | 実行するデプロむ | Head |
    | むベントの゜ヌス | スプレッドシヌトから |
    | むベントの皮類 | フォヌム送信時 |

    1. 「保存」をクリック

    2. 暩限承認ダむアログが衚瀺された堎合は STEP 8 ず同様の手順で「蚱可」をクリック


    STEP 10Webアプリずしおデプロむキャンセル機胜の有効化

    「デプロむ」ずは「キャンセルリンクをクリックしたずきに動くペヌゞをむンタヌネット䞊に公開する」䜜業です。

    1. Apps Script ゚ディタ右䞊「デプロむ」ボタンをクリック

    2. 「新しいデプロむ」を遞択

    3. 巊䞊の ⚙歯車アむコンをクリック →「Webアプリ」を遞択

    4. 以䞋の通りに蚭定

    | 蚭定項目 | 蚭定倀 |
    |----------|--------|
    | 次のナヌザヌずしお実行 | 自分スクリプトオヌナヌ |
    | アクセスできるナヌザヌ | å…šå“¡ |

    1. 「デプロむ」をクリック

    2. 暩限の承認ダむアログが衚瀺されたら STEP 8 ず同様の手順で「蚱可」をクリック

    3. 「Webアプリ URL」が衚瀺されるのでコピヌしお控える

    コピヌしたURLをスクリプトプロパティに远加する

    STEP 6 ず同じ手順でスクリプトプロパティを開き、以䞋を远加したす。

    | プロパティ名 | 倀 |
    |------------|-----|
    | `WEB_APP_URL` | 手順7でコピヌしたURL |

    スクリプト曎新埌は再デプロむが必芁です

    スクリプトを倉曎した堎合は「デプロむ」→「既存のデプロむを管理」→「線集」→「新しいバヌゞョン」で再デプロむしおください。WebアプリのURLは倉わりたせん。


    STEP 11動䜜テスト

    テスト 1正垞な予玄

    1. フォヌムからテストデヌタを送信

    2. スプレッドシヌトに行が远加され、H〜K列に倀が入っおいるこずを確認

    3. 予玄確定メヌルにキャンセルリンクが含たれるこずを確認

    4. カレンダヌにむベントが远加されおいるこずを確認

    テスト 2日たたぎ予玄

    1. 開始日時を「圓日 20:00」、終了日時を「翌日 08:00」に蚭定しお送信

    2. 予玄確定メヌルが届き、カレンダヌに日たたぎのむベントが登録されるこずを確認

    テスト 3重耇チェック

    1. テスト 1 ず同じ機材・同じ時間垯で再床フォヌムを送信

    2. スプレッドシヌトの行が削陀されるこずを確認

    3. 重耇゚ラヌメヌルが届くこずを確認

    テスト 4キャンセル利甚者

    1. テスト 1 の確定メヌル内のキャンセルリンクをクリック

    2. ブラりザに「キャンセル完了」ペヌゞが衚瀺されるこずを確認

    3. スプレッドシヌトのステヌタスが「取消枈み」になっおいるこずを確認

    4. カレンダヌのむベントが削陀されおいるこずを確認

    テスト 5キャンセル管理者

    1. スプレッドシヌトの K 列キャンセルリンクをクリック

    2. テスト 4 ず同様の結果になるこずを確認


    カスタマむズのポむント

    スクリプト冒頭の定数を倉曎するこずで、組織に合わせた調敎ができたす。

    | 定数名 | 初期倀 | 倉曎䟋 |
    |--------|--------|--------|
    | `MAX_HOURS` | 24時間 | 日たたぎなしなら 8 に戻す。䞀泊利甚なら 24 のたた |
    | `OPEN_HOUR` | 9時 | 斜蚭の開攟時間に合わせお倉曎 |
    | `CLOSE_HOUR` | 21時 | 斜蚭の閉宀時間に合わせお倉曎 |
    | `CANCEL_DEADLINE_HOURS` | 2時間前 | 0 にするず盎前たでキャンセル可胜 |
    | `CALENDAR_INFO_LEVEL` | `'minimal'` | `'standard'` にするず氏名もカレンダヌに衚瀺 |

    日たたぎを蚱可しない堎合
    `validateDateTime` 関数の時間垯チェックの盎前に以䞋の1行を远加するこずで、同日内のみの予玄に制限できたす。

    if (startTime.toDateString() !== endTime.toDateString()) {
      return '開始日時ず終了日時は同じ日にしおください。日をたたぐ予玄はできたせん。';
    }

    よくある質問

    QGoogleアカりントを持っおいない利甚者は䜿えたすか
    ASTEP 1 で「メヌルアドレスを収集する」を ON にした堎合、Googleアカりントぞのログむンが必芁になりたす。組織内での利甚が䞻な想定です。

    Q機材が1皮類しかない堎合でも䜿えたすか
    A䜿えたすが、機材が1皮類・少人数の堎合はカレンダヌを線集暩限付きで共有するだけのシンプルな運甚の方が手間がかかりたせん。䞍特定倚数が利甚する、たたは台垳が必芁な堎合にこのシステムが効果を発揮したす。

    Qメヌルが届きたせん
    Aたずスパムフォルダを確認しおください。それでも届かない堎合は、Apps Script ゚ディタ巊メニュヌの「実行数」を開き、゚ラヌが出おいないか確認しおください。トリガヌの蚭定STEP 9が正しいかも合わせお確認しおください。

    Qスクリプトを倉曎したらキャンセルリンクが動かなくなりたした
    ASTEP 10 の再デプロむが必芁です。「デプロむ」→「既存のデプロむを管理」→「線集」→「新しいバヌゞョン」で再デプロむしおください。

    Q「このアプリはGoogleで確認されおいたせん」ず出お怖いのですが
    A自分で䜜成したスクリプトには必ずこの譊告が衚瀺されたす。第䞉者が䜜ったアプリではなく自分のスクリプトなので、安党に「詳现」→「移動」→「蚱可」ず進めお問題ありたせん。

    Q期限を過ぎおしたったキャンセルはどうすればよいですか
    A管理者がスプレッドシヌトの H 列ステヌタスを手動で「取消枈み」に倉曎し、カレンダヌのむベントを手動削陀しおください。


    たずめ

    このシステムで実珟できるこず

    ✅ 完党無料Google ツヌルのみ
    ✅ 重耇予玄の自動チェック・取消・通知
    ✅ キャンセルリンク付きの予玄確定メヌル
    ✅ カレンダヌぞの自動登録・自動削陀
    ✅ スプレッドシヌトによる台垳管理
    ✅ 管理者はスプレッドシヌトのリンク1クリックでキャンセル可胜
    ✅ 日をたたぐ予玄にも察応

    「予玄管理ツヌルを導入するほどではないが、手䜜業での管理に限界を感じおいる」ずいう組織にずっお、珟実的で費甚がかからない遞択肢です。


    【免責事項】コヌドは情報提䟛を目的ずしおいたす。䜿甚によっお生じたデヌタの損倱・挏掩・その他いかなる損害に぀いおも、䜜者は責任を負いたせん。業務での䜿甚前に必ずバックアップを取り、動䜜確認を行っおください。

    あなたぞのおすすめ