今日來談談前端設計。
對 NGO 志工與職工來說,最方便的檢視方式莫過於直接在手機上的 Google Calendar(Google 日曆)App 隨時檢視場地空檔與佔用時段。我們要建立手風琴內嵌預約輸入表單,並將這套前端介面透過 Google Apps Script (GAS) 直接串接 Google Calendar API!只要使用者一按送出,後端就會自動在指定的共享 Google 日曆上建立事件,達到「一處預約,所有人 Google 日曆同步可見」的極致體驗!
為了達成零軟體成本且全員共享,我們設計了以下極簡資料流:
[前端:React 預約輸入畫面]
│ (POST 送出預約 JSON)
▼
[後端:Google Apps Script (GAS) Web App]
│
├───► 1. 呼叫 CalendarApp 寫入指定 Google Calendar (公開/共享日曆)
│
└───► 2. 寫入 Google Sheets (作為底層 Audit Log 備份)
│
▼
[所有人手機上的 Google Calendar App 即時顯示事件]
貫徹之前的「零彈窗(No-Modal Flow)」原則,當使用者在日曆點擊空白時段時,畫面會平滑展開這張預約輸入卡片,並自動帶入選取的房間與時間。
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>
);
};
前端傳送 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 Apps Script 直連 Google Calendar。現在,只要任何人填寫表單,預約資訊就會零時差出現在全機構所有人的 Google 日曆 App 上!
明日繼續……