iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Build on Google AI

零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統系列 第 9 篇

【Day 9】預約輸入畫面與 Google Calendar 直連整合:一鍵建立全員共享日曆事件

  • 分享至 

  • xImage
  •  

今日來談談前端設計。
對 NGO 志工與職工來說,最方便的檢視方式莫過於直接在手機上的 Google Calendar(Google 日曆)App 隨時檢視場地空檔與佔用時段。我們要建立手風琴內嵌預約輸入表單,並將這套前端介面透過 Google Apps Script (GAS) 直接串接 Google Calendar API!只要使用者一按送出,後端就會自動在指定的共享 Google 日曆上建立事件,達到「一處預約,所有人 Google 日曆同步可見」的極致體驗!

一、 系統架構:從表單輸入到 Google 日曆同步

為了達成零軟體成本且全員共享,我們設計了以下極簡資料流:

[前端:React 預約輸入畫面]
         │ (POST 送出預約 JSON)
         ▼
[後端:Google Apps Script (GAS) Web App]
         │
         ├───► 1. 呼叫 CalendarApp 寫入指定 Google Calendar (公開/共享日曆)
         │
         └───► 2. 寫入 Google Sheets (作為底層 Audit Log 備份)
         │
         ▼
[所有人手機上的 Google Calendar App 即時顯示事件]

為什麼選用 Google Calendar 做全員同步?

  1. 零學習成本: 年長志工與青年職工手機裡早就裝有 Google 日曆,免安裝額外 App。
  2. 主動推播與提醒: Google Calendar 原生支援活動開始前的 Phone Notification 提醒。
  3. 跨平台全員可見: 只要將該 Google 日曆設為「組織內共享」或「公開檢視」,所有關注該日曆的人都能即時掌握場地狀態。

二、 前端介面:手風琴內嵌預約輸入表單 (Inline Booking Form)

貫徹之前的「零彈窗(No-Modal Flow)」原則,當使用者在日曆點擊空白時段時,畫面會平滑展開這張預約輸入卡片,並自動帶入選取的房間與時間。

  1. React 表單元件實作 (InlineBookingForm.tsx)
TypeScript

import React, { useState, useEffect } from 'react';

export interface BookingFormData {
  roomId: string;
  roomName: string;
  applicantName: string;
  applicantDept: string;
  startTime: string;
  endTime: string;
  purpose: string;
}

interface Props {
  isOpen: boolean;
  selectedSlot: { roomId: string; roomName: string; start: string; end: string } | null;
  onClose: () => void;
  onSubmitSuccess: () => void;
}

export const InlineBookingForm: React.FC<Props> = ({
  isOpen,
  selectedSlot,
  onClose,
  onSubmitSuccess,
}) => {
  const [formData, setFormData] = useState<BookingFormData>({
    roomId: 'RM-A1',
    roomName: '活動室 A1',
    applicantName: '',
    applicantDept: '青年服務部',
    startTime: '',
    endTime: '',
    purpose: '',
  });

  const [isSubmitting, setIsSubmitting] = useState(false);

  // 當點擊 Day 8 日曆時,自動帶入點選的時間與房型
  useEffect(() => {
    if (selectedSlot) {
      setFormData((prev) => ({
        ...prev,
        roomId: selectedSlot.roomId,
        roomName: selectedSlot.roomName,
        startTime: selectedSlot.start,
        endTime: selectedSlot.end,
      }));
    }
  }, [selectedSlot]);

  if (!isOpen) return null;

  // 處理表單提交
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      // 呼叫 GAS Web App API
      const GAS_API_URL = 'YOUR_GOOGLE_APPS_SCRIPT_WEB_APP_URL';
      
      const response = await fetch(GAS_API_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'text/plain;charset=utf-8' },
        body: JSON.stringify({
          action: 'createBooking',
          ...formData,
        }),
      });

      const result = await response.json();

      if (result.status === 'SUCCESS') {
        alert('🎉 預約成功!已同步發布至 Google 日曆。');
        onSubmitSuccess();
        onClose();
      } else {
        alert(`預約失敗:${result.message}`);
      }
    } catch (err) {
      alert('網路連線異常,請稍後再試。');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="w-full bg-slate-50 border-2 border-indigo-200 rounded-xl p-5 mb-6 shadow-inner transition-all">
      <div className="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
        <h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
          📝 新增場地預約 (直連 Google 日曆)
        </h3>
        <button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-sm font-semibold">
          ▲ 收合卡片
        </button>
      </div>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          {/* 預約場地 */}
          <div>
            <label className="block text-xs font-semibold text-slate-600 mb-1">預約場地/資源</label>
            <select
              value={formData.roomId}
              onChange={(e) => setFormData({ ...formData, roomId: e.target.value })}
              className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
            >
              <option value="RM-A1">活動室 A1</option>
              <option value="RM-B1">活動室 B1</option>
              <option value="EQ-PROJ">移動投影機</option>
            </select>
          </div>

          {/* 申請部門與姓名 */}
          <div>
            <label className="block text-xs font-semibold text-slate-600 mb-1">申請部門 / 同工姓名</label>
            <input
              type="text"
              required
              value={formData.applicantName}
              onChange={(e) => setFormData({ ...formData, applicantName: e.target.value })}
              placeholder="例如:青年服務部 - Alex"
              className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
            />
          </div>

          {/* 開始時間 */}
          <div>
            <label className="block text-xs font-semibold text-slate-600 mb-1">開始時間</label>
            <input
              type="datetime-local"
              required
              value={formData.startTime}
              onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
              className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
            />
          </div>

          {/* 結束時間 */}
          <div>
            <label className="block text-xs font-semibold text-slate-600 mb-1">結束時間</label>
            <input
              type="datetime-local"
              required
              value={formData.endTime}
              onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
              className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
            />
          </div>
        </div>

        {/* 活動用途 */}
        <div>
          <label className="block text-xs font-semibold text-slate-600 mb-1">活動主題與備註</label>
          <input
            type="text"
            required
            value={formData.purpose}
            onChange={(e) => setFormData({ ...formData, purpose: e.target.value })}
            placeholder="例如:社區青少年營隊籌備會議 (預計 15 人)"
            className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
          />
        </div>

        {/* 提交按鈕 */}
        <div className="flex justify-end gap-3 pt-3 border-t">
          <button
            type="button"
            onClick={onClose}
            className="px-4 py-2 border border-slate-300 text-slate-600 rounded-lg hover:bg-slate-100 min-h-[48px]"
          >
            取消
          </button>
          <button
            type="submit"
            disabled={isSubmitting}
            className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg shadow min-h-[48px] flex items-center gap-2"
          >
            {isSubmitting ? '同步寫入 Google 日曆中...' : '📅 送出預約並同步至 Google 日曆'}
          </button>
        </div>
      </form>
    </div>
  );
};

三、 後端 API:Google Apps Script (GAS) 直連 Google Calendar 核心實作

前端傳送 JSON 後,後端利用 GAS 原生的 CalendarApp 服務,直接將活動建立在指定的公用 Google 日曆上。GAS 後端程式碼 (Code.gs)

JavaScript

// 設定共享的 Google Calendar ID (可於 Google 日曆設定中複製)
const GOOGLE_CALENDAR_ID = 'your_organization_calendar_id@group.calendar.google.com';

function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);

    if (data.action === 'createBooking') {
      return handleCreateBooking(data);
    }

    return ContentService.createTextOutput(
      JSON.stringify({ status: 'ERROR', message: '無效的 Action' })
    ).setMimeType(ContentService.MimeType.JSON);

  } catch (error) {
    return ContentService.createTextOutput(
      JSON.stringify({ status: 'ERROR', message: error.toString() })
    ).setMimeType(ContentService.MimeType.JSON);
  }
}

function handleCreateBooking(data) {
  // 1. 取得指定的 Google 日曆物件
  const calendar = CalendarApp.getCalendarById(GOOGLE_CALENDAR_ID);
  
  if (!calendar) {
    throw new Error('找不到指定的 Google Calendar,請檢查 Calendar ID 設定。');
  }

  // 2. 解析時間格式 (ISO String -> Date Object)
  const start = new Date(data.startTime);
  const end = new Date(data.endTime);

  // 3. 組合日曆事件標題與內文
  // 範例標題:[活動室 A1] 社區青少年營隊籌備會議 - Alex
  const eventTitle = `[${data.roomName}] ${data.purpose} (${data.applicantName})`;
  const eventDescription = `申請部門/姓名:${data.applicantName}\n預約場地:${data.roomName}\n活動內容:${data.purpose}\n預約系統自動建立。`;

  // 4. 在 Google Calendar 上建立活動事件
  const event = calendar.createEvent(eventTitle, start, end, {
    description: eventDescription,
    location: data.roomName,
  });

  // 5. 設定事件顏色標籤 (區分不同房間,例如:活動室 A1 設為綠色)
  if (data.roomId === 'RM-A1') {
    event.setColor(CalendarApp.EventColor.PALE_GREEN);
  } else if (data.roomId === 'RM-B1') {
    event.setColor(CalendarApp.EventColor.PALE_BLUE);
  }

  // 6. (可選) 同時寫入 Google Sheets 備份 Audit Log
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Bookings');
  if (sheet) {
    sheet.appendRow([
      event.getId(), // 紀錄 Google Event ID
      data.applicantName,
      data.roomId,
      data.startTime,
      data.endTime,
      data.purpose,
      new Date(),
    ]);
  }

  // 7. 回傳成功訊息與 Google 日曆事件 ID
  return ContentService.createTextOutput(
    JSON.stringify({
      status: 'SUCCESS',
      eventId: event.getId(),
      message: '成功建立 Google 日曆事件!',
    })
  ).setMimeType(ContentService.MimeType.JSON);
}

四、 全員共享 Google 日曆的設定步驟

要讓全機構的同工與志工都能在手機上看見這些預約,只需進行一次性設定:

  1. 取得 Google Calendar ID:
  • 開啟 Google 日曆網頁版 $\rightarrow$ 在左側建立一個專用日曆(例如:「NGO 場地預約看板」)。
  • 進入該日曆的「設定與共用」 $\rightarrow$ 滾動至「整合日曆」段落,複製 日曆 ID 貼入 GAS 的 GOOGLE_CALENDAR_ID 變數。
  1. 設定存取權限:
  • 內部共享: 在「與特定使用者共用」中,將全體同工的 Email 加入,權限設為「檢視所有活動詳細資料」。
  • 公開發布: 若要讓志工或民眾直接訂閱,可勾選「向公開存取權限開放」,並提供訂閱連結。

五、 結語

今天我們打造了直觀的手風琴預約輸入表單,並成功以 Google Apps Script 直連 Google Calendar。現在,只要任何人填寫表單,預約資訊就會零時差出現在全機構所有人的 Google 日曆 App 上!

明日繼續……


上一篇
【Day 8】規格定義與資料庫設計:用 Google Notebook 產出 SRS 與 Google Sheets Schema 藍圖
下一篇
【Day 10】極端狀況防禦策略:運用 LockService 與衝突檢查打造「零重複預約」系統
系列文
零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統 共 11 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言