Google Apps Script 教學版

第二堂課實務版|以營隊活動管理中樞為例

從一筆活動資料開始,建立中控台、報名表單、行政文件、通知狀態、成果檢查與 NotebookLM 資料包。Apps Script 建流程,AI 協助草稿,承辦人確認正式內容。

一、系統目標

這份講義同時提供「完整一次貼上版」與「分檔學習版」。實作時建議先貼完整一次貼上版;講解程式架構時,再依分檔版說明每個模組的責任。

承辦人中控台集中活動、文件、報名、通知、期限與 NotebookLM 匯出狀態。
報名與文件自動建立 Google Form、家長通知書、同意書、標案需求與處室通知草稿。
成果與交接活動後整理成果檢查與 NotebookLM 資料包,方便下次沿用。
安全邊界:正式公告、收費、保險、交通、安全、採購、核銷與個資處理仍由承辦人或權責單位確認。

二、資料流與工作流

活動清單
場次設定
活動資料包
部分場次報名
通知管理
逐場簽到
成果檢查
NotebookLM 匯出
交接複盤
下次沿用

功能說明

三、營隊/活動管理中樞功能總覽

這 16 個功能不是獨立按鈕,而是一條完整的活動行政工作流。從建案、報名、場次、簽到、任務追蹤,到最後匯出 NotebookLM 資料包,協助承辦人把活動資料整理成可追蹤、可同步、可檢查、可交接、可匯出的流程。

初始化系統 → 建立活動 → 建立資料包 → 報名與統計 → 場次與簽到 → 任務與期限 → 行政文件 → NotebookLM 資料包

一、系統初始化與活動建案

01 初始化中控台 建案
功能定位

這是整套系統的起點,用來建立或重建活動管理中樞所需的基礎工作表、欄位、格式與下拉選單。

執行後會產生或更新
  • 活動主檔
  • 報名資料表
  • 場次設定表
  • 簽到預備資料表
  • 任務總覽
  • 行政文件模板
  • 期限狀態表
  • 系統設定表
適合使用時機

第一次建立展示環境時使用,或需要重建完整中控台時使用。正式資料環境中不建議反覆執行。

02 建立/開啟活動新增表單 建案
功能定位

建立或開啟活動新增表單,讓承辦人透過 Google 表單輸入活動基本資料,作為活動建案入口。

執行後會產生或更新
  • 活動新增表單
  • 活動基本資料欄位
  • 表單回應試算表
  • 活動資料收件入口
適合使用時機

承辦人要新增一個活動,但不想直接編輯試算表時使用。

03 使用表單新增活動時才按:匯入活動回應 建案
功能定位

將活動新增表單的回應資料匯入活動主檔,讓表單資料正式進入中控台管理流程。

執行後會產生或更新
  • 建立活動 ID
  • 將活動名稱寫入活動主檔
  • 整理承辦人、日期、地點等欄位
  • 標記活動狀態
  • 建立後續資料包所需的基礎資訊
適合使用時機

只有當活動是透過第 2 項表單新增時才需要執行。若活動資料是直接手動填入活動主檔,通常不需要執行。

04 從選取列建立活動資料包 建案
功能定位

根據目前選取的活動資料列,建立該活動專屬的資料夾、文件與管理架構。

執行後會產生或更新
  • 活動專屬資料夾
  • 報名資料子資料夾
  • 簽到資料子資料夾
  • 行前通知子資料夾
  • 成果照片子資料夾
  • 活動摘要文件
  • 任務清單
  • 場次資料
  • NotebookLM 用資料來源資料夾
適合使用時機

活動正式成立後使用。這是從一筆活動資料生成完整行政資料包的核心功能。

二、報名表單與資料同步

05 建立報名表單並產生 50 筆假資料 報名
功能定位

建立活動報名表單,並自動產生 50 筆模擬報名資料,方便課程展示與測試。

執行後會產生或更新
  • Google 報名表
  • 報名表連結
  • 報名回應試算表
  • 50 筆假報名資料
  • 報名資料同步欄位
  • 活動主檔中的報名表狀態
適合使用時機

教學展示、系統測試或需要模擬大量報名資料時使用。正式活動上線前,應確認是否需要關閉假資料功能。

06 同步報名資料與統計 報名
功能定位

將報名表單收到的資料同步回中控台,並產生可供行政判斷的統計資訊。

執行後會產生或更新
  • 報名資料表
  • 報名名冊
  • 報名人數統計
  • 場次人數統計
  • 學校、年級、身分別統計
  • 飲食、交通、特殊需求統計
  • 活動主檔中的報名狀態
  • 報名進度
適合使用時機

報名期間、報名截止後,或承辦人需要掌握最新報名狀況時使用。

三、場次與簽到管理

07 產生/更新場次設定 場次
功能定位

將一個活動拆解成一個或多個可管理的場次。

執行後會產生或更新
  • 場次 ID
  • 活動 ID
  • 場次名稱
  • 場次日期
  • 開始時間
  • 結束時間
  • 地點
  • 講師
  • 場次容量
  • 是否需要簽到
  • 是否需要簽退
  • 場次狀態
適合使用時機

活動需要分場次、分梯次、分時段或分課程模組管理時使用。

08 更新場次統計 場次
功能定位

根據報名資料與場次設定,重新計算各場次的人數、名額與出席狀態。

執行後會產生或更新
  • 每場報名人數
  • 每場剩餘名額
  • 每場候補人數
  • 每場簽到人數
  • 每場缺席人數
  • 每場出席率
  • 每場是否額滿
  • 是否需要加開場次的參考資訊
適合使用時機

報名資料更新後、活動前確認各場人數時,或活動後統計出席狀況時使用。

09 產生場次簽到預備資料 簽到
功能定位

依照場次和報名名單,先整理每一場的應到人員清單,作為後續簽到比對基礎。

執行後會產生或更新
  • 場次簽到預備表
  • 每場應到人員名單
  • 姓名
  • 單位/學校
  • 身分
  • 報名序號
  • 簽到狀態欄
  • 簽到時間欄
  • 備註欄
  • QR Code 或簽到代碼欄位
適合使用時機

建立簽到表單之前使用。這一步的重點是先確認每場應該有哪些人出席。

10 產生場次簽到表單 簽到
功能定位

根據場次簽到預備資料,建立實際給學員或參與者填寫的簽到表單。

執行後會產生或更新
  • 每個場次的簽到表單
  • 簽到表單連結
  • 簽到 QR Code
  • 簽到回應表
  • 場次資料中的簽到表連結
適合使用時機

活動開始前,需要開放簽到或產生簽到 QR Code 時使用。

11 一鍵更新場次簽到狀態 簽到
功能定位

將簽到表單回應與原本報名名單比對,更新每位參與者的出席狀態。

執行後會產生或更新
  • 已簽到
  • 未簽到
  • 重複簽到
  • 非報名者簽到
  • 場次錯誤
  • 時間異常
  • 缺席
  • 遲到
  • 場次統計
  • 報名資料表
  • 活動主檔
  • 任務總覽
適合使用時機

活動當天或活動結束後使用,用來快速完成出席判斷與統計。

四、任務、期限與行政文件

12 更新任務總覽 任務
功能定位

重新整理整個活動的任務進度,讓承辦人知道目前做到哪裡、還缺什麼。

執行後會產生或更新
  • 任務是否建立
  • 任務狀態
  • 任務完成率
  • 活動目前階段
  • 到期狀態
  • 缺漏項目
  • 負責人
  • 最後更新時間
適合使用時機

完成重要步驟後、活動前檢查進度時,或需要向他人交接目前狀態時使用。

13 建立行政文件模板 文件
功能定位

建立活動常用的行政文件模板,並可依活動資料帶入基本內容,降低重複撰寫文件的負擔。

執行後會產生或更新
  • 活動計畫
  • 報名簡章
  • 行前通知
  • 家長通知單
  • 會議紀錄
  • 簽到表
  • 成果報告
  • 經費概算表
  • 核銷檢核表
  • 感謝狀或證明文件
  • 承辦人測試信
  • 寄送通知範本
適合使用時機

活動資料確定後,需要產生行政文件、通知文件或成果文件時使用。

14 更新期限狀態 任務
功能定位

檢查所有任務、報名、場次或文件的期限狀態,協助承辦人掌握時間壓力與逾期風險。

執行後會產生或更新
  • 尚未開始
  • 進行中
  • 即將到期
  • 已逾期
  • 已完成
  • 需要提醒
  • 可結案
適合使用時機

活動前後、每日檢查進度,或承辦人需要確認哪些項目已經接近期限時使用。

15 寄送測試信給承辦人 通知
功能定位

寄送一封測試信給活動承辦人,確認系統寄信功能與信件內容是否正常。

執行後會產生或更新
  • 測試信件
  • 活動名稱
  • 活動日期
  • 報名表連結
  • 簽到表連結
  • 任務提醒
  • 系統測試訊息
適合使用時機

正式寄送行前通知、錄取通知、提醒信之前使用,用來降低正式寄信出錯的風險。

五、AI 知識包與 NotebookLM

16 匯出 NotebookLM 資料包 AI資料包
功能定位

將活動資料整理成適合 NotebookLM 或 Gemini 使用的知識資料包,讓 AI 可以讀懂整個活動脈絡。

執行後會產生或更新
  • 活動基本資料
  • 報名統計
  • 場次統計
  • 簽到統計
  • 任務進度
  • 行政文件
  • 活動成果摘要
  • 待辦與缺漏清單
  • Markdown 或 Google Docs 格式的統整文件
適合使用時機

活動資料整理完成後、撰寫成果報告前,或需要交給 NotebookLM 進行摘要、問答、成果整理時使用。

教學展示建議

最適合在課堂上串接展示的功能:從選取列建立活動資料包、建立報名表單並產生 50 筆假資料、同步報名資料與統計、產生場次簽到預備資料、產生場次簽到表單、一鍵更新場次簽到狀態、更新任務總覽、更新期限狀態、匯出 NotebookLM 資料包。

這幾個功能串起來,可以讓學員看見活動資料如何從表單、名冊、簽到、統計一路進入 AI 可讀的知識包。

四、課堂操作步驟

  1. 在 Google Sheet 開啟 Apps Script。
  2. 打開或建立 Code.gs。
  3. 複製「完整一次貼上版」全部內容並貼上。
  4. 儲存後重新整理試算表,確認出現「營隊活動管理」分組選單。
  5. 研習展示時,從「一、研習展示」執行「初始化展示環境」。
  6. 可以先執行「一、研習展示/新增示範資料:新北AI研習」,快速產生一筆活動與四筆場次。
  7. 也可以到「00_中控台」開啟活動新增表單,填寫自己的活動。
  8. 只有使用活動新增表單時,才執行「二、正式活動建案/匯入活動表單資料」。若已使用新北示範資料,這一步不用執行。
  9. 到「01_活動清單」選取活動列。
  10. 正式活動可執行「二、正式活動建案/一鍵建立正式活動骨架」。
  11. 研習展示可執行「三、報名管理/建立報名表單+50筆假資料(研習用)」。
  12. 若使用正式表單回應,再執行「三、報名管理/更新報名名冊與統計」。
  13. 檢查「04_場次簽到」是否已產生學員 × 場次的預備資料。
  14. 執行「四、場次與簽到/產生場次簽到表單」,每場會建立一張簽到 Google Form。
  15. 學員填寫簽到表單後,執行「四、場次與簽到/比對簽到回應並更新出席狀態」。
  16. 執行「五、任務、期限與日曆/更新任務進度與重要行事」,檢查「05_任務總覽」是否呈現承辦人每日要看的任務。
  17. 活動後到「七、成果與結案」整理成果並匯出 NotebookLM 資料包。
講師備註:分檔版是開發與講解用;完整貼上版是給學員少出錯的實作版。兩者內容由同一組來源檔產生。

五、完整一次貼上版

學員實作時建議使用本段:在 Apps Script 的 Code.gs 全選貼上即可。下方分檔版保留給講解與後續維護。

檔案 101

營隊活動管理中樞_完整貼上版.gs|完整一次貼上版(建議學員實作使用)

/**
 * 營隊/活動管理中樞|完整一次貼上版
 *
 * 產生方式:
 * node build_combined_camp_gas.js
 *
 * 教學使用方式:
 * 1. 在 Google Sheet 開啟「擴充功能」→「Apps Script」。
 * 2. 建立或打開 Code.gs。
 * 3. 全選貼上本檔全部內容。
 * 4. 儲存後重新整理試算表。
 * 5. 使用「營隊活動管理」選單開始操作。
 *
 * 維護方式:
 * 正式開發請優先修改分檔版本,再重新執行產生器,不建議直接手改本合併檔。
 */


/**
 * ============================================================
 * Config.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:集中設定與欄位常數。
 *
 * 教學重點:
 * 1. 所有工作表名稱、欄位、資料夾名稱集中管理。
 * 2. 後續模組只引用這裡的設定,不把欄位名稱寫散在各處。
 */
const CAMP_CONFIG = {
  SYSTEM_NAME: '營隊活動管理中樞',
  ROOT_FOLDER_ID: '',
  CALENDAR_ID: '',
  TIMEZONE: 'Asia/Taipei',
  DATE_SOON_DAYS: 7,
  HUMAN_CONFIRMATION_NOTICE: '本系統協助建檔、整理、提醒與產出草稿。正式公告、收費、保險、交通、安全、個資、採購與核銷仍須由承辦人或權責單位確認。'
};

const CAMP_SHEETS = {
  DASHBOARD: '00_中控台',
  ACTIVITIES: '01_活動清單',
  SESSIONS: '02_場次設定',
  REGISTRATIONS: '03_報名資料',
  ATTENDANCE: '04_場次簽到',
  TASK_OVERVIEW: '05_任務總覽',
  TASKS: '05_任務追蹤',
  DOCUMENTS: '06_文件清單',
  NOTIFICATIONS: '07_通知管理',
  OUTCOMES: '08_成果檢查',
  NOTEBOOK: '09_NotebookLM匯出',
  SETTINGS: '10_系統設定'
};

const CAMP_SHEET_ORDER = [
  CAMP_SHEETS.DASHBOARD,
  CAMP_SHEETS.ACTIVITIES,
  CAMP_SHEETS.SESSIONS,
  CAMP_SHEETS.REGISTRATIONS,
  CAMP_SHEETS.ATTENDANCE,
  CAMP_SHEETS.TASK_OVERVIEW,
  CAMP_SHEETS.TASKS,
  CAMP_SHEETS.DOCUMENTS,
  CAMP_SHEETS.NOTIFICATIONS,
  CAMP_SHEETS.OUTCOMES,
  CAMP_SHEETS.NOTEBOOK,
  CAMP_SHEETS.SETTINGS
];

const CAMP_ACTIVITY_HEADERS = [
  '活動ID',
  '活動名稱',
  '活動類型',
  '活動日期',
  '活動開始日',
  '活動結束日',
  '每日預設開始時間',
  '每日預設結束時間',
  '活動天數',
  '場次數',
  '是否多日活動',
  '是否開放部分場次報名',
  '是否需要每日簽到',
  '是否需要課後回饋',
  '是否需要成果繳交',
  '報名截止日',
  '同意書回收日',
  '活動地點',
  '參加對象',
  '預估人數',
  '承辦處室',
  '承辦人',
  '承辦人Email',
  '是否收費',
  '費用說明',
  '交通方式',
  '是否需要保險',
  '是否需要家長同意書',
  '專案資料夾連結',
  '報名表單連結',
  '報名表單編輯連結',
  '報名回應表連結',
  '活動摘要連結',
  '家長通知書連結',
  '家長同意書連結',
  '標案需求表連結',
  '處室通知草稿連結',
  '導師行前提醒連結',
  '成果檢查表連結',
  'NotebookLM資料包連結',
  '報名人數',
  '同意參加',
  '不參加',
  '特殊需求數',
  '目前狀態',
  '最近更新時間',
  '備註',
  '報名表模板類型',
  '任務完成率'
];

const CAMP_SESSION_HEADERS = [
  '活動ID',
  '場次ID',
  '場次日期',
  '開始時間',
  '結束時間',
  '場次名稱',
  '場次主題',
  '場次目標',
  '講師',
  '助教或協作人員',
  '地點或線上連結',
  '本場需要設備',
  '講義連結',
  '簽到表單連結',
  '回饋表單連結',
  '錄影連結',
  '報名人數',
  '簽到人數',
  '出席率',
  '課前任務完成率',
  '課後回饋數',
  '成果整理狀態',
  '風險等級',
  '目前狀態',
  '備註',
  '任務完成率',
  '任務完成狀態'
];

const CAMP_REGISTRATION_HEADERS = [
  '活動ID',
  '活動名稱',
  '時間戳記',
  '學生姓名',
  '班級',
  '座號',
  '家長姓名',
  '家長Email',
  '家長電話',
  '是否參加',
  '是否全程參加',
  '選擇參加場次',
  '需要研習時數',
  '學習期待',
  '目前行政痛點',
  '是否同意通知',
  '飲食需求',
  '特殊需求',
  '同意書狀態',
  '是否可提供 NotebookLM',
  '來源分頁',
  '備註'
];

const CAMP_ATTENDANCE_HEADERS = [
  '活動ID',
  '場次ID',
  '場次名稱',
  '場次日期',
  '學生或學員姓名',
  '學校或單位',
  'Email',
  '報名資料Key',
  '是否報名本場',
  '簽到狀態',
  '簽到時間',
  '簽退狀態',
  '簽退時間',
  '可核發時數',
  '備註'
];

const CAMP_TASK_HEADERS = [
  '活動ID',
  '場次ID',
  'WBS編碼',
  '階段',
  '任務名稱',
  '具體任務',
  '交付物',
  '完成判準',
  '負責角色',
  '負責人',
  '建議開始日期',
  '建議完成日期',
  '實際完成日期',
  '前置任務ID',
  '是否關鍵任務',
  '需要文件或表單',
  '狀態',
  '優先程度',
  '卡關原因',
  '備註',
  '最後更新時間'
];

const CAMP_TASK_OVERVIEW_HEADERS = [
  '任務ID',
  '活動ID',
  '活動名稱',
  '場次ID',
  '場次名稱',
  '階段',
  '任務名稱',
  '任務類型',
  '負責人',
  '截止日',
  '到期日',
  '狀態',
  '優先程度',
  '卡關原因/下一步',
  '備註',
  '重要行事',
  '日曆標題',
  '日曆日期',
  '開始時間',
  '結束時間',
  '日曆事件ID',
  '日曆同步狀態',
  '最後同步時間',
  '日曆回寫狀態',
  '日曆最後讀取時間',
  '完成時間',
  '完成日',
  '最後更新時間'
];

const CAMP_DOCUMENT_HEADERS = [
  '活動ID',
  '活動名稱',
  '文件類型',
  '文件名稱',
  '用途',
  '連結',
  '產生方式',
  '狀態',
  '最後更新時間',
  '承辦人確認',
  '備註'
];

const CAMP_NOTIFICATION_HEADERS = [
  '活動ID',
  '通知名稱',
  '通知對象',
  '通知管道',
  '建議通知日期',
  '實際通知日期',
  '文件連結',
  '寄送或通知狀態',
  '承辦人確認',
  '備註'
];

const CAMP_OUTCOME_HEADERS = [
  '活動ID',
  '成果項目',
  '是否完成',
  '檔案或資料夾連結',
  '是否含個資',
  '是否可提供 NotebookLM',
  '負責人',
  '備註'
];

const CAMP_NOTEBOOK_HEADERS = [
  '活動ID',
  '資料區塊',
  '內容摘要',
  '來源文件或工作表',
  '是否納入匯出',
  '備註'
];

const CAMP_SETTINGS_HEADERS = ['設定項目', '設定值', '說明'];

const CAMP_SUBFOLDER_NAMES = [
  '00_活動基本資料',
  '01_報名表單與回覆',
  '02_家長通知與同意書',
  '03_處室聯繫與採購',
  '04_任務追蹤與會議紀錄',
  '05_活動照片與成果',
  '06_成果檢查與結案',
  '07_NotebookLM資料包',
  '99_系統產生文件'
];

const CAMP_TASK_STATUS_VALUES = ['未開始', '進行中', '待確認', '已完成', '延後', '取消', '不適用'];
const CAMP_PRIORITY_VALUES = ['高', '中', '低'];
const CAMP_TASK_STATUS_COLORS = {
  '未開始': '#f3f4f6',
  '進行中': '#dbeafe',
  '待確認': '#fef3c7',
  '已完成': '#dcfce7',
  '延後': '#fee2e2',
  '取消': '#f5f5f5',
  '不適用': '#ede9fe'
};
const CAMP_PRIORITY_COLORS = {
  '高': '#fecaca',
  '中': '#fde68a',
  '低': '#bbf7d0'
};
const CAMP_OVERDUE_ROW_COLOR = '#fee2e2';
const CAMP_SOON_ROW_COLOR = '#fef9c3';
const CAMP_CONFIRM_VALUES = ['未確認', '待確認', '已確認', '退回修改', '不適用'];
const CAMP_YES_NO_VALUES = ['是', '否', '待確認', '不適用'];
const CAMP_DATE_STATUS_VALUES = ['未到期', '即將到期', '逾期', '已完成', '無日期'];
const CAMP_SESSION_STATUS_VALUES = ['未開始', '準備中', '進行中', '已完成', '待補件', '取消'];
const CAMP_RISK_LEVEL_VALUES = ['低', '中', '高', '極高'];
const CAMP_ATTENDANCE_STATUS_VALUES = ['未簽到', '已簽到', '請假', '缺席', '不適用'];
const CAMP_REGISTRATION_TEMPLATE_TYPES = ['自動判斷', '教師研習', '線上研習', '學生營隊', '校外教學', '親子活動', '競賽活動', '自訂'];


/**
 * ============================================================
 * AiPrompts.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:提示詞集中管理。
 *
 * MVP 先採用「提示詞模板」。Gemini API 可於進階階段再接上。
 */
const CAMP_PROMPTS = {
  activityDiagnosis: [
    '你是一位熟悉台灣國小行政流程的 AI 行政專案顧問。',
    '請根據以下活動建案資料,協助判斷這個活動是否適合用活動型行政專案方式管理。',
    '請用表格回答:',
    '1. 目前資訊已清楚的部分',
    '2. 目前資訊不足的部分',
    '3. 最優先要確認的 5 件事',
    '4. 如果直接執行,可能出現的行政風險',
    '5. 建議建立哪些 Google Workspace 文件、表單、資料夾或提醒',
    '',
    '活動資料:',
    '{{ACTIVITY_DATA}}'
  ].join('\n'),
  parentNotice: [
    '你是一位熟悉台灣國小行政語氣的學校行政助理。',
    '請根據以下活動資料,產出一份給家長的活動通知書草稿。',
    '請注意:',
    '1. 語氣正式、清楚、親切。',
    '2. 日期、費用、集合時間若資料不足,請用【待確認】標示。',
    '3. 涉及收費、保險、交通、安全者,請提醒承辦人正式確認。',
    '4. 不要自行編造不存在的資料。',
    '5. 使用繁體中文。',
    '6. 最後加上「承辦人確認事項」。',
    '',
    '活動資料:',
    '{{ACTIVITY_DATA}}'
  ].join('\n'),
  riskCheck: [
    '你是一位協助學校行政人員進行活動風險盤點的 AI 助理。',
    '請根據活動資料、任務、通知與成果檢查,找出可能風險。',
    '請分成時程、人員、學生安全、交通、家長溝通、經費核銷、成果結案、個資與資料安全。',
    '每一項請列出風險內容、可能造成的問題、預防方式、是否需要提醒、承辦人需確認事項。',
    '',
    '活動資料:',
    '{{ACTIVITY_DATA}}'
  ].join('\n'),
  notebook: [
    '你是一位協助學校行政人員整理 NotebookLM 專案資料包的助理。',
    '請根據以下活動資料與工作流摘要,整理成可匯入 NotebookLM 的乾淨資料包。',
    '請不要放入學生電話、健康細節、身分資料或不必要個資。',
    '請包含:活動基本資料、重要時程、任務狀態、通知紀錄、成果檢查、下次辦理建議、可問 NotebookLM 的問題。',
    '',
    '工作流資料:',
    '{{WORKFLOW_DATA}}'
  ].join('\n')
};


/**
 * ============================================================
 * Utilities.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:共用工具函式。
 */
function getActiveSpreadsheet_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  if (!ss) throw new Error('請先開啟已綁定 Apps Script 的 Google 試算表。');
  return ss;
}

function alert_(message) {
  try {
    SpreadsheetApp.getUi().alert(String(message));
  } catch (error) {
    Logger.log(String(message));
  }
}

function getOrCreateSheet_(ss, name) {
  return ss.getSheetByName(name) || ss.insertSheet(name);
}

function ensureHeaders_(sheet, headers) {
  const width = headers.length;
  const existing = sheet.getRange(1, 1, 1, Math.max(width, sheet.getLastColumn() || width)).getValues()[0];
  let changed = false;
  headers.forEach(function(header, index) {
    if (existing[index] !== header) changed = true;
  });
  if (changed) {
    sheet.getRange(1, 1, 1, width).setValues([headers]);
  }
  formatHeaderRow_(sheet, width);
  return buildHeaderMap_(sheet);
}

function formatHeaderRow_(sheet, columns) {
  sheet.getRange(1, 1, 1, columns)
    .setFontWeight('bold')
    .setBackground('#dbeafe')
    .setHorizontalAlignment('center')
    .setWrap(true);
  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, columns);
}

function buildHeaderMap_(sheet) {
  const lastColumn = sheet.getLastColumn();
  if (!lastColumn) return {};
  const headers = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
  const map = {};
  headers.forEach(function(header, index) {
    if (!header) return;
    map[String(header).trim()] = index + 1;
  });
  return map;
}

function readCellByHeader_(sheet, row, headerMap, header) {
  const column = headerMap[header];
  if (!column) return '';
  return sheet.getRange(row, column).getValue();
}

function writeCellByHeader_(sheet, row, headerMap, header, value) {
  const column = headerMap[header];
  if (!column) return;
  sheet.getRange(row, column).setValue(value);
}

function readRowObject_(sheet, row, headers) {
  const map = buildHeaderMap_(sheet);
  const object = {};
  headers.forEach(function(header) {
    object[header] = readCellByHeader_(sheet, row, map, header);
  });
  return object;
}

function getSelectedActivityRowContext_() {
  const ss = getActiveSpreadsheet_();
  const sheet = ss.getActiveSheet();
  if (sheet.getName() !== CAMP_SHEETS.ACTIVITIES) {
    throw new Error('請先到「' + CAMP_SHEETS.ACTIVITIES + '」選取一筆活動資料列。');
  }
  const row = sheet.getActiveCell().getRow();
  if (row <= 1) throw new Error('請選取活動資料列,不要選標題列。');
  const headerMap = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  const activity = readActivityFromRow_(sheet, row, headerMap);
  if (!activity.name) throw new Error('選取列缺少「活動名稱」。');
  return { ss: ss, sheet: sheet, row: row, headerMap: headerMap, activity: activity };
}

function readActivityFromRow_(sheet, row, headerMap) {
  return {
    id: String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim(),
    name: String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim(),
    type: String(readCellByHeader_(sheet, row, headerMap, '活動類型') || '').trim(),
    registrationTemplateType: String(readCellByHeader_(sheet, row, headerMap, '報名表模板類型') || '').trim(),
    activityDate: readCellByHeader_(sheet, row, headerMap, '活動日期'),
    startDate: readCellByHeader_(sheet, row, headerMap, '活動開始日'),
    endDate: readCellByHeader_(sheet, row, headerMap, '活動結束日'),
    defaultStartTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設開始時間') || '').trim(),
    defaultEndTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設結束時間') || '').trim(),
    activityDays: readCellByHeader_(sheet, row, headerMap, '活動天數'),
    sessionCount: readCellByHeader_(sheet, row, headerMap, '場次數'),
    isMultiDay: String(readCellByHeader_(sheet, row, headerMap, '是否多日活動') || '').trim(),
    allowPartialSessions: String(readCellByHeader_(sheet, row, headerMap, '是否開放部分場次報名') || '').trim(),
    dailyAttendanceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要每日簽到') || '').trim(),
    feedbackRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要課後回饋') || '').trim(),
    outcomeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要成果繳交') || '').trim(),
    registrationDeadline: readCellByHeader_(sheet, row, headerMap, '報名截止日'),
    consentDeadline: readCellByHeader_(sheet, row, headerMap, '同意書回收日'),
    location: String(readCellByHeader_(sheet, row, headerMap, '活動地點') || '').trim(),
    participants: String(readCellByHeader_(sheet, row, headerMap, '參加對象') || '').trim(),
    estimatedCount: readCellByHeader_(sheet, row, headerMap, '預估人數'),
    department: String(readCellByHeader_(sheet, row, headerMap, '承辦處室') || '').trim(),
    owner: String(readCellByHeader_(sheet, row, headerMap, '承辦人') || '').trim(),
    ownerEmail: String(readCellByHeader_(sheet, row, headerMap, '承辦人Email') || '').trim(),
    chargeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否收費') || '').trim(),
    feeNote: String(readCellByHeader_(sheet, row, headerMap, '費用說明') || '').trim(),
    transport: String(readCellByHeader_(sheet, row, headerMap, '交通方式') || '').trim(),
    insuranceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要保險') || '').trim(),
    consentRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要家長同意書') || '').trim(),
    folderUrl: String(readCellByHeader_(sheet, row, headerMap, '專案資料夾連結') || '').trim(),
    registrationFormUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單連結') || '').trim(),
    registrationFormEditUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單編輯連結') || '').trim(),
    summaryDocUrl: String(readCellByHeader_(sheet, row, headerMap, '活動摘要連結') || '').trim(),
    parentNoticeUrl: String(readCellByHeader_(sheet, row, headerMap, '家長通知書連結') || '').trim(),
    parentConsentUrl: String(readCellByHeader_(sheet, row, headerMap, '家長同意書連結') || '').trim(),
    procurementUrl: String(readCellByHeader_(sheet, row, headerMap, '標案需求表連結') || '').trim(),
    officeEmailUrl: String(readCellByHeader_(sheet, row, headerMap, '處室通知草稿連結') || '').trim(),
    teacherReminderUrl: String(readCellByHeader_(sheet, row, headerMap, '導師行前提醒連結') || '').trim(),
    outcomeUrl: String(readCellByHeader_(sheet, row, headerMap, '成果檢查表連結') || '').trim(),
    notebookUrl: String(readCellByHeader_(sheet, row, headerMap, 'NotebookLM資料包連結') || '').trim(),
    note: String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim()
  };
}

function activityToText_(activity) {
  return [
    '活動ID:' + valueOrPending_(activity.id),
    '活動名稱:' + valueOrPending_(activity.name),
    '活動類型:' + valueOrPending_(activity.type),
    '報名表模板類型:' + valueOrPending_(activity.registrationTemplateType || '自動判斷'),
    '活動日期:' + formatDateValue_(activity.activityDate),
    '活動開始日:' + formatDateValue_(activity.startDate),
    '活動結束日:' + formatDateValue_(activity.endDate),
    '每日預設時間:' + valueOrPending_(activity.defaultStartTime || '09:00') + '-' + valueOrPending_(activity.defaultEndTime || '12:00'),
    '活動天數:' + valueOrPending_(activity.activityDays),
    '場次數:' + valueOrPending_(activity.sessionCount),
    '是否多日活動:' + valueOrPending_(activity.isMultiDay),
    '是否開放部分場次報名:' + valueOrPending_(activity.allowPartialSessions),
    '是否需要每日簽到:' + valueOrPending_(activity.dailyAttendanceRequired),
    '是否需要課後回饋:' + valueOrPending_(activity.feedbackRequired),
    '是否需要成果繳交:' + valueOrPending_(activity.outcomeRequired),
    '報名截止日:' + formatDateValue_(activity.registrationDeadline),
    '同意書回收日:' + formatDateValue_(activity.consentDeadline),
    '活動地點:' + valueOrPending_(activity.location),
    '參加對象:' + valueOrPending_(activity.participants),
    '預估人數:' + valueOrPending_(activity.estimatedCount),
    '承辦處室:' + valueOrPending_(activity.department),
    '承辦人:' + valueOrPending_(activity.owner),
    '承辦人Email:' + valueOrPending_(activity.ownerEmail),
    '是否收費:' + valueOrPending_(activity.chargeRequired),
    '費用說明:' + valueOrPending_(activity.feeNote),
    '交通方式:' + valueOrPending_(activity.transport),
    '是否需要保險:' + valueOrPending_(activity.insuranceRequired),
    '是否需要家長同意書:' + valueOrPending_(activity.consentRequired),
    '備註:' + valueOrPending_(activity.note),
    '',
    '場次摘要:',
    formatSessionsText_(activity.id)
  ].join('\n');
}

function generateActivityId_() {
  const dateText = Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd-HHmmss');
  const props = PropertiesService.getScriptProperties();
  const key = 'CAMP_ACTIVITY_SERIAL_' + Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd');
  const next = Number(props.getProperty(key) || '0') + 1;
  props.setProperty(key, String(next));
  return 'ACT-' + dateText + '-' + ('000' + next).slice(-3);
}

function nowText_() {
  return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm:ss');
}

function nowMinuteText_() {
  return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm');
}

function parseDate_(value) {
  if (value instanceof Date && !isNaN(value.getTime())) return value;
  if (!value) return null;
  const date = new Date(value);
  return isNaN(date.getTime()) ? null : date;
}

function addDays_(date, days) {
  if (!date) return null;
  const copy = new Date(date.getTime());
  copy.setDate(copy.getDate() + days);
  return copy;
}

function formatDateValue_(value) {
  const date = parseDate_(value);
  if (!date) return value ? String(value) : '待確認';
  return Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd');
}

function effectiveActivityStartDate_(activity) {
  return parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}

function effectiveActivityEndDate_(activity) {
  return parseDate_(activity.endDate) || parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}

function calculateActivityDays_(activity) {
  const startDate = effectiveActivityStartDate_(activity);
  const endDate = effectiveActivityEndDate_(activity);
  if (!startDate || !endDate) return '';
  const start = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
  const end = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
  return Math.max(1, Math.floor((end.getTime() - start.getTime()) / 86400000) + 1);
}

function formatPercent_(numerator, denominator) {
  if (!denominator) return '0%';
  return Math.round((Number(numerator || 0) / Number(denominator || 0)) * 100) + '%';
}

function isSameDate_(a, b) {
  const da = parseDate_(a);
  const db = parseDate_(b);
  if (!da || !db) return false;
  return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
}

function calculateDateStatus_(value, done) {
  if (done) return '已完成';
  const date = parseDate_(value);
  if (!date) return '無日期';
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const target = new Date(date.getTime());
  target.setHours(0, 0, 0, 0);
  const diffDays = Math.floor((target.getTime() - today.getTime()) / 86400000);
  if (diffDays < 0) return '逾期';
  if (diffDays <= CAMP_CONFIG.DATE_SOON_DAYS) return '即將到期';
  return '未到期';
}

function sanitizeFileName_(value) {
  return String(value || '未命名活動').replace(/[\\/:*?"<>|#%{}~&]/g, '_').slice(0, 120);
}

function valueOrPending_(value) {
  if (value === null || value === undefined || value === '') return '待確認';
  return String(value);
}

function extractIdFromUrl_(url) {
  const text = String(url || '');
  const match = text.match(/[-\w]{25,}/);
  return match ? match[0] : '';
}

function getRowsByActivityId_(sheet, activityId) {
  if (!sheet || sheet.getLastRow() <= 1) return [];
  const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
  const headers = values[0];
  const idIndex = headers.indexOf('活動ID');
  if (idIndex < 0) return [];
  return values.slice(1).filter(function(row) {
    return String(row[idIndex] || '') === String(activityId || '');
  });
}

function buildRowsByHeader_(sheet) {
  if (!sheet || sheet.getLastRow() <= 1) return [];
  const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
  const headers = values[0];
  return values.slice(1).map(function(row) {
    const object = {};
    headers.forEach(function(header, index) {
      if (header) object[String(header)] = row[index];
    });
    return object;
  });
}

function appendDocumentListRow_(activity, type, name, purpose, url, method, status, confirmation, note) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS);
  ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
  sheet.appendRow([
    activity.id,
    activity.name,
    type,
    name,
    purpose,
    url,
    method || '系統模板',
    status || '草稿',
    nowText_(),
    confirmation || '待確認',
    note || ''
  ]);
}

function setValidationList_(sheet, startRow, column, rowCount, values) {
  if (rowCount <= 0) return;
  const rule = SpreadsheetApp.newDataValidation()
    .requireValueInList(values, true)
    .setAllowInvalid(true)
    .build();
  sheet.getRange(startRow, column, rowCount, 1).setDataValidation(rule);
}


/**
 * ============================================================
 * SheetSetup.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:中控 Spreadsheet 初始化。
 */
function initializeSheets_(ss) {
  CAMP_SHEET_ORDER.forEach(function(name) {
    getOrCreateSheet_(ss, name);
  });

  setupDashboardSheet_(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
  setupActivityListSheet_(ss.getSheetByName(CAMP_SHEETS.ACTIVITIES));
  setupSessionSheet_(ss.getSheetByName(CAMP_SHEETS.SESSIONS));
  setupRegistrationSheet_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS));
  setupAttendanceSheet_(ss.getSheetByName(CAMP_SHEETS.ATTENDANCE));
  setupTaskOverviewSheet_(ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW));
  setupTaskSheet_(ss.getSheetByName(CAMP_SHEETS.TASKS));
  setupDocumentListSheet_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS));
  setupNotificationSheet_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS));
  setupOutcomeSheet_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES));
  setupNotebookSheet_(ss.getSheetByName(CAMP_SHEETS.NOTEBOOK));
  setupSettingsSheet_(ss.getSheetByName(CAMP_SHEETS.SETTINGS));
  restoreActivityIntakeLinksToDashboard_(ss);

  CAMP_SHEET_ORDER.forEach(function(name, index) {
    ss.setActiveSheet(ss.getSheetByName(name));
    ss.moveActiveSheet(index + 1);
  });
  ss.setActiveSheet(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
}

function setupDashboardSheet_(sheet) {
  sheet.clear();
  sheet.getRange('A1').setValue(CAMP_CONFIG.SYSTEM_NAME).setFontSize(18).setFontWeight('bold');
  sheet.getRange('A3:D23').setValues([
    ['指標', '目前數值', '狀態', '提示'],
    ['活動總數', 0, '', ''],
    ['多日活動數', 0, '', ''],
    ['場次總數', 0, '', ''],
    ['報名總人數', 0, '', ''],
    ['場次簽到總筆數', 0, '', ''],
    ['平均出席率', '0%', '', ''],
    ['未完成任務數', 0, '', ''],
    ['今日到期任務', 0, '', ''],
    ['延宕任務數', 0, '', ''],
    ['高風險場次數', 0, '', ''],
    ['待確認文件數', 0, '', ''],
    ['NotebookLM 匯出數', 0, '', ''],
    ['最後更新時間', nowText_(), '', ''],
    ['重要行事總數', 0, '', ''],
    ['已同步日曆數', 0, '', ''],
    ['日曆回寫成功數', 0, '', ''],
    ['日曆事件遺失數', 0, '', ''],
    ['日曆同步失敗數', 0, '', ''],
    ['今日到期重要行事', 0, '', ''],
    ['延宕重要行事', 0, '', '']
  ]);
  formatDashboardKpiLayout_(sheet);

  sheet.getRange('F3:G7').setValues([
    ['快速連結', '連結或說明'],
    ['活動新增表單', '尚未建立'],
    ['活動新增表單編輯', '尚未建立'],
    ['操作提醒', '示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。'],
    ['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE]
  ]);
  sheet.getRange('F3:G3').setFontWeight('bold').setBackground('#dbeafe');
  sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
  sheet.getRange('G4:G7').setWrap(true);

  sheet.getRange('A25').setValue('場次進度總覽').setFontWeight('bold');
  sheet.getRange('A26:K26').setValues([['活動ID', '活動名稱', '場次ID', '場次日期', '場次名稱', '報名人數', '簽到人數', '出席率', '課前任務完成率', '目前狀態', '風險等級']]).setFontWeight('bold').setBackground('#e0f2fe');
  sheet.getRange('A38').setValue('紅燈任務').setFontWeight('bold');
  sheet.getRange('A39:G39').setValues([['活動ID', '場次ID', '任務名稱', '負責人', '建議完成日期', '狀態', '卡關原因']]).setFontWeight('bold').setBackground('#fee2e2');
  sheet.getRange('A53').setValue('今日/明日場次').setFontWeight('bold');
  sheet.getRange('A54:G54').setValues([['日期', '活動名稱', '場次ID', '場次名稱', '時間', '地點', '狀態']]).setFontWeight('bold').setBackground('#dcfce7');
  sheet.getRange('A68').setValue('下一步建議').setFontWeight('bold');
  sheet.getRange('A69').setValue('請先選擇一條流程:示範資料流程,或活動新增表單流程。示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。').setWrap(true);
  sheet.setColumnWidth(1, 220);
  sheet.setColumnWidth(2, 120);
  sheet.setColumnWidth(3, 130);
  sheet.setColumnWidth(4, 320);
  sheet.setColumnWidth(6, 180);
  sheet.setColumnWidth(7, 520);
}

function ensureDashboardVisualLayout_(sheet) {
  sheet.getRange('A3:D3').setValues([['指標', '目前數值', '狀態', '提示']]);
  sheet.getRange('E3:E7').clearContent().clearFormat();
  sheet.getRange('A4:A23').setValues([
    ['活動總數'],
    ['多日活動數'],
    ['場次總數'],
    ['報名總人數'],
    ['場次簽到總筆數'],
    ['平均出席率'],
    ['未完成任務數'],
    ['今日到期任務'],
    ['延宕任務數'],
    ['高風險場次數'],
    ['待確認文件數'],
    ['NotebookLM 匯出數'],
    ['最後更新時間'],
    ['重要行事總數'],
    ['已同步日曆數'],
    ['日曆回寫成功數'],
    ['日曆事件遺失數'],
    ['日曆同步失敗數'],
    ['今日到期重要行事'],
    ['延宕重要行事']
  ]);
  formatDashboardKpiLayout_(sheet);
  sheet.getRange('F3:G3').setValues([['快速連結', '連結或說明']]).setFontWeight('bold').setBackground('#dbeafe');
  sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
  sheet.getRange('G4:G7').setWrap(true);
  sheet.setColumnWidth(1, 220);
  sheet.setColumnWidth(2, 120);
  sheet.setColumnWidth(3, 130);
  sheet.setColumnWidth(4, 320);
  sheet.setColumnWidth(5, 28);
  sheet.setColumnWidth(6, 180);
  sheet.setColumnWidth(7, 520);
}

function formatDashboardKpiLayout_(sheet) {
  sheet.getRange('A3:D3')
    .setFontWeight('bold')
    .setBackground('#dbeafe')
    .setHorizontalAlignment('center');
  sheet.getRange('A4:A23').setFontWeight('bold').setBackground('#f8fafc');
  sheet.getRange('B4:B23').setHorizontalAlignment('right');
  sheet.getRange('C4:C23').setHorizontalAlignment('center');
  sheet.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}

function setupActivityListSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('報名表模板類型') + 1, 200, CAMP_REGISTRATION_TEMPLATE_TYPES);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否收費') + 1, 200, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要保險') + 1, 200, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要家長同意書') + 1, 200, CAMP_YES_NO_VALUES);
}

function setupTaskOverviewSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
  formatTaskOverviewHeader_(sheet);
}

function setupSessionSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('目前狀態') + 1, 500, CAMP_SESSION_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('風險等級') + 1, 500, CAMP_RISK_LEVEL_VALUES);
}

function setupRegistrationSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
}

function setupAttendanceSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
  setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽到狀態') + 1, 1000, CAMP_ATTENDANCE_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽退狀態') + 1, 1000, ['未簽退', '已簽退', '不適用']);
}

function setupTaskSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_TASK_HEADERS);
  setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
}

function setupDocumentListSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
  setValidationList_(sheet, 2, CAMP_DOCUMENT_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}

function setupNotificationSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  setValidationList_(sheet, 2, CAMP_NOTIFICATION_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}

function setupOutcomeSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
  setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否完成') + 1, 500, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否含個資') + 1, 500, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否可提供 NotebookLM') + 1, 500, CAMP_YES_NO_VALUES);
}

function setupNotebookSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
  setValidationList_(sheet, 2, CAMP_NOTEBOOK_HEADERS.indexOf('是否納入匯出') + 1, 500, CAMP_YES_NO_VALUES);
}

function setupSettingsSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
  if (sheet.getLastRow() === 1) {
    sheet.getRange(2, 1, 7, 3).setValues([
      ['SYSTEM_NAME', CAMP_CONFIG.SYSTEM_NAME, '系統名稱'],
      ['ROOT_FOLDER_ID', CAMP_CONFIG.ROOT_FOLDER_ID || '空白時使用目前試算表所在資料夾或我的雲端硬碟根目錄', '平台根資料夾設定'],
      ['CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定'],
      ['TIMEZONE', CAMP_CONFIG.TIMEZONE, '日期計算使用時區'],
      ['DATE_SOON_DAYS', CAMP_CONFIG.DATE_SOON_DAYS, '幾天內視為即將到期'],
      ['GEMINI_API_KEY', '未在本教學 MVP 啟用', 'API key 不寫入試算表'],
      ['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE, '正式內容仍由承辦人確認']
    ]);
  }
  ensureSettingExists_(sheet, 'CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定');
}

function ensureSettingExists_(sheet, key, value, note) {
  const map = buildHeaderMap_(sheet);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (String(readCellByHeader_(sheet, row, map, '設定項目') || '') === String(key || '')) {
      return;
    }
  }
  sheet.appendRow([key, value, note]);
}

function updateDashboard_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
  ensureDashboardVisualLayout_(dashboard);
  restoreActivityIntakeLinksToDashboard_(ss);
  updateDashboardKpis_();
  updateDashboardStatusLights_();
  updateDashboardSessionOverview_();
  updateDashboardRedTasks_();
  updateDashboardTodaySessions_();
}

function restoreActivityIntakeLinksToDashboard_(ss) {
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
  const map = buildHeaderMap_(settings);
  if (!map['設定項目'] || !map['設定值']) return;

  const links = {};
  for (let row = 2; row <= settings.getLastRow(); row++) {
    const key = String(readCellByHeader_(settings, row, map, '設定項目') || '');
    if (key === '活動新增表單' || key === '活動新增表單編輯') {
      links[key] = readCellByHeader_(settings, row, map, '設定值');
    }
  }

  if (links['活動新增表單']) {
    dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
    dashboard.getRange('G4').setValue(links['活動新增表單']).setWrap(true);
  }
  if (links['活動新增表單編輯']) {
    dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
    dashboard.getRange('G5').setValue(links['活動新增表單編輯']).setWrap(true);
  }
}


/**
 * ============================================================
 * DriveService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:Drive 資料夾操作。
 */
function getCampRootFolder_() {
  if (CAMP_CONFIG.ROOT_FOLDER_ID) {
    try {
      return DriveApp.getFolderById(CAMP_CONFIG.ROOT_FOLDER_ID);
    } catch (error) {
      Logger.log('ROOT_FOLDER_ID 無效,改用試算表所在資料夾:' + error.message);
    }
  }

  const ss = getActiveSpreadsheet_();
  const file = DriveApp.getFileById(ss.getId());
  const parents = file.getParents();
  const parent = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
  return getOrCreateSubFolder_(parent, '營隊活動管理中樞_資料包');
}

function createActivityFolder_(activity) {
  const root = getCampRootFolder_();
  const dateText = formatDateValue_(activity.activityDate).replace(/\//g, '');
  const folderName = sanitizeFileName_((activity.id || generateActivityId_()) + '_' + dateText + '_' + activity.name);
  return root.createFolder(folderName);
}

function createActivitySubfolders_(folder) {
  const map = {};
  CAMP_SUBFOLDER_NAMES.forEach(function(name) {
    map[name] = getOrCreateSubFolder_(folder, name);
  });
  return map;
}

function getOrCreateSubFolder_(parent, name) {
  const folders = parent.getFoldersByName(name);
  return folders.hasNext() ? folders.next() : parent.createFolder(name);
}

function moveFileToFolder_(fileId, folder) {
  const file = DriveApp.getFileById(fileId);
  folder.addFile(file);
  const parents = file.getParents();
  while (parents.hasNext()) {
    const parent = parents.next();
    if (parent.getId() !== folder.getId()) {
      try {
        parent.removeFile(file);
      } catch (error) {
        Logger.log('移除原父資料夾失敗,可忽略:' + error.message);
      }
    }
  }
  return file;
}

function getFolderFromActivityUrl_(url) {
  const id = extractIdFromUrl_(url);
  if (!id) return null;
  try {
    return DriveApp.getFolderById(id);
  } catch (error) {
    Logger.log('無法由活動資料夾連結取得資料夾:' + error.message);
    return null;
  }
}

function getActivitySubfolderMap_(folder) {
  const map = {};
  CAMP_SUBFOLDER_NAMES.forEach(function(name) {
    map[name] = getOrCreateSubFolder_(folder, name);
  });
  return map;
}


/**
 * ============================================================
 * SessionService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:多日活動、場次與簽到服務。
 */
function generateSessionsForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  generateSessionsForActivity_(context.activity);
  updateActivitySessionFields_(context);
  updateSessionStats_();
  updateDashboard_();
  alert_('場次設定已產生或更新完成。');
}

function updateSessionStats() {
  updateSessionStats_();
  updateDashboard_();
  alert_('場次統計已更新。');
}

function generateAttendanceRowsForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  generateAttendanceRowsForActivity_(context.activity.id);
  updateSessionStats_();
  updateDashboard_();
  alert_('場次簽到預備資料已產生。');
}

function generateSessionsForActivity_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  if (!activity.id) return [];
  const existing = getSessionsByActivityId_(activity.id);
  if (existing.length > 0) return existing;

  const startDate = effectiveActivityStartDate_(activity);
  const endDate = effectiveActivityEndDate_(activity) || startDate;
  if (!startDate) return [];

  const rows = [];
  let index = 1;
  for (let d = new Date(startDate.getTime()); d <= endDate; d.setDate(d.getDate() + 1)) {
    const sessionId = 'S' + ('0' + index).slice(-2);
    rows.push(buildSessionSheetRow_({
      '活動ID': activity.id,
      '場次ID': sessionId,
      '場次日期': new Date(d.getTime()),
      '開始時間': activity.defaultStartTime || '09:00',
      '結束時間': activity.defaultEndTime || '12:00',
      '場次名稱': '第 ' + index + ' 日',
      '講師': activity.owner || '',
      '地點或線上連結': activity.location || '',
      '報名人數': 0,
      '簽到人數': 0,
      '出席率': '0%',
      '課前任務完成率': '0%',
      '課後回饋數': 0,
      '成果整理狀態': '未整理',
      '風險等級': '低',
      '目前狀態': '未開始',
      '備註': '',
      '任務完成率': '0%',
      '任務完成狀態': '未開始'
    }));
    index++;
  }
  if (rows.length) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
  }
  return getSessionsByActivityId_(activity.id);
}

function buildSessionSheetRow_(sessionData) {
  return CAMP_SESSION_HEADERS.map(function(header) {
    return Object.prototype.hasOwnProperty.call(sessionData, header) ? sessionData[header] : '';
  });
}

function getSessionsByActivityId_(activityId) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  const rows = buildRowsByHeader_(sheet).filter(function(row) {
    return String(row['活動ID'] || '') === String(activityId || '');
  });
  return rows.map(function(row) {
    return {
      activityId: row['活動ID'],
      sessionId: row['場次ID'],
      date: row['場次日期'],
      startTime: row['開始時間'],
      endTime: row['結束時間'],
      title: row['場次名稱'],
      topic: row['場次主題'],
      instructor: row['講師'],
      location: row['地點或線上連結'],
      registrationCount: row['報名人數'] || 0,
      checkinCount: row['簽到人數'] || 0,
      attendanceRate: row['出席率'] || '0%',
      preTaskRate: row['課前任務完成率'] || '0%',
      outcomeStatus: row['成果整理狀態'] || '',
      riskLevel: row['風險等級'] || '',
      status: row['目前狀態'] || ''
    };
  });
}

function formatSessionsText_(activityId) {
  if (!activityId) return '尚未建立場次。';
  try {
    const sessions = getSessionsByActivityId_(activityId);
    if (!sessions.length) return '尚未建立場次。';
    return sessions.map(function(session) {
      return [
        session.sessionId,
        formatDateValue_(session.date),
        valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
        valueOrPending_(session.title),
        valueOrPending_(session.topic),
        valueOrPending_(session.location),
        valueOrPending_(session.status)
      ].join('|');
    }).join('\n');
  } catch (error) {
    Logger.log('場次摘要讀取失敗:' + error.message);
    return '場次摘要讀取失敗。';
  }
}

function appendSessionsTable_(body, activityId) {
  const sessions = getSessionsByActivityId_(activityId);
  if (!sessions.length) {
    body.appendParagraph('尚未建立場次。');
    return;
  }
  const rows = [['場次ID', '日期', '時間', '場次名稱', '主題', '地點']];
  sessions.forEach(function(session) {
    rows.push([
      session.sessionId,
      formatDateValue_(session.date),
      valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
      valueOrPending_(session.title),
      valueOrPending_(session.topic),
      valueOrPending_(session.location)
    ]);
  });
  appendDocTable_(body, rows);
}

function updateSessionStats_() {
  const ss = getActiveSpreadsheet_();
  const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
  const sessionMap = buildHeaderMap_(sessionSheet);
  const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
  const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
  const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));

  for (let row = 2; row <= sessionSheet.getLastRow(); row++) {
    const activityId = readCellByHeader_(sessionSheet, row, sessionMap, '活動ID');
    const sessionId = readCellByHeader_(sessionSheet, row, sessionMap, '場次ID');
    if (!activityId || !sessionId) continue;
    const registered = registrationRows.filter(function(item) {
      return String(item['活動ID'] || '') === String(activityId)
        && selectedSessionsContain_(item['選擇參加場次'], sessionId);
    }).length;
    const checkedIn = attendanceRows.filter(function(item) {
      return String(item['活動ID'] || '') === String(activityId)
        && String(item['場次ID'] || '') === String(sessionId)
        && String(item['簽到狀態'] || '') === '已簽到';
    }).length;
    const preTasks = taskRows.filter(function(item) {
      return String(item['活動ID'] || '') === String(activityId)
        && String(item['場次ID'] || '') === String(sessionId)
        && String(item['階段'] || '').indexOf('課前') >= 0;
    });
    const donePreTasks = preTasks.filter(function(item) {
      return String(item['狀態'] || '') === '已完成';
    }).length;
    writeCellByHeader_(sessionSheet, row, sessionMap, '報名人數', registered);
    writeCellByHeader_(sessionSheet, row, sessionMap, '簽到人數', checkedIn);
    writeCellByHeader_(sessionSheet, row, sessionMap, '出席率', formatPercent_(checkedIn, registered));
    writeCellByHeader_(sessionSheet, row, sessionMap, '課前任務完成率', formatPercent_(donePreTasks, preTasks.length));
  }
  updateActivitySessionCounts_();
}

function updateActivitySessionCounts_() {
  const ss = getActiveSpreadsheet_();
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
  for (let row = 2; row <= activitySheet.getLastRow(); row++) {
    const activity = readActivityFromRow_(activitySheet, row, activityMap);
    if (!activity.id) continue;
    const sessionCount = getSessionsByActivityId_(activity.id).length;
    const days = calculateActivityDays_(activity);
    writeCellByHeader_(activitySheet, row, activityMap, '活動天數', days);
    writeCellByHeader_(activitySheet, row, activityMap, '場次數', sessionCount);
    if (!activity.isMultiDay || activity.isMultiDay === '待確認') {
      writeCellByHeader_(activitySheet, row, activityMap, '是否多日活動', Number(days || 0) > 1 ? '是' : '否');
    }
  }
}

function updateActivitySessionFields_(context) {
  const sessions = getSessionsByActivityId_(context.activity.id);
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動天數', calculateActivityDays_(context.activity));
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '場次數', sessions.length);
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '是否多日活動', sessions.length > 1 ? '是' : '否');
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
}

function selectedSessionsContain_(selectedText, sessionId) {
  return parseSelectedSessionIds_(selectedText).indexOf(String(sessionId || '')) >= 0;
}

function parseSelectedSessionIds_(text) {
  const raw = String(text || '');
  const direct = raw
    .split(/[,,\n]/)
    .map(function(part) {
      const match = String(part).match(/S\d{2}/);
      return match ? match[0] : '';
    })
    .filter(Boolean);

  const inferred = [];
  if (raw.indexOf('6月15日') >= 0 || raw.indexOf('6/15') >= 0) inferred.push('S01');
  if (raw.indexOf('6月18日') >= 0 || raw.indexOf('6/18') >= 0) inferred.push('S02');
  if (raw.indexOf('6月22日') >= 0 || raw.indexOf('6/22') >= 0) inferred.push('S03');
  if (raw.indexOf('6月25日') >= 0 || raw.indexOf('6/25') >= 0) inferred.push('S04');

  return direct.concat(inferred).filter(function(value, index, list) {
    return value && list.indexOf(value) === index;
  });
}

function buildRegistrationKey_(registration, sessionId) {
  return [
    registration.activityId || '',
    registration.email || '',
    registration.name || '',
    sessionId || ''
  ].join('|');
}

function ensureAttendanceRowsForRegistration_(registration) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
  ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
  const sessions = getRegistrationSessions_(registration);
  if (!sessions.length) return 0;
  const existing = buildExistingAttendanceKeys_(sheet);
  const rows = [];
  sessions.forEach(function(session) {
    const key = buildRegistrationKey_(registration, session.sessionId);
    if (existing[key]) return;
    rows.push([
      registration.activityId,
      session.sessionId,
      session.title,
      session.date,
      registration.name,
      registration.unit,
      registration.email,
      key,
      '是',
      '未簽到',
      '',
      '未簽退',
      '',
      '',
      ''
    ]);
    existing[key] = true;
  });
  if (rows.length) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_ATTENDANCE_HEADERS.length).setValues(rows);
  }
  return rows.length;
}

function getRegistrationSessions_(registration) {
  const sessions = getSessionsByActivityId_(registration.activityId);
  if (!sessions.length) return [];
  const fullText = String(registration.fullParticipation || '');
  if (fullText === '是' || fullText.indexOf('全程') >= 0) return sessions;
  const selectedIds = parseSelectedSessionIds_(registration.selectedSessions);
  if (!selectedIds.length) return sessions.length === 1 ? sessions : [];
  return sessions.filter(function(session) {
    return selectedIds.indexOf(session.sessionId) >= 0;
  });
}

function buildExistingAttendanceKeys_(sheet) {
  const rows = buildRowsByHeader_(sheet);
  const keys = {};
  rows.forEach(function(row) {
    const key = row['報名資料Key'];
    if (key) keys[String(key)] = true;
  });
  return keys;
}

function generateAttendanceRowsForActivity_(activityId) {
  const ss = getActiveSpreadsheet_();
  const registrations = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS)).filter(function(row) {
    return String(row['活動ID'] || '') === String(activityId || '');
  });
  let added = 0;
  registrations.forEach(function(row) {
    added += ensureAttendanceRowsForRegistration_({
      activityId: row['活動ID'],
      name: row['學生姓名'] || row['學生或學員姓名'],
      unit: row['班級'] || row['學校或單位'],
      email: row['家長Email'] || row['Email'],
      fullParticipation: row['是否全程參加'],
      selectedSessions: row['選擇參加場次']
    });
  });
  return added;
}

function updateDashboardKpis_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  const activityRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES));
  const sessionRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS));
  const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
  const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
  const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));
  const taskOverviewRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW));
  const documentRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS));
  const checkedIn = attendanceRows.filter(function(row) { return row['簽到狀態'] === '已簽到'; }).length;
  const multiDay = activityRows.filter(function(row) {
    return row['是否多日活動'] === '是' || Number(row['活動天數'] || 0) > 1;
  }).length;
  const todayDueTasks = taskRows.filter(function(row) {
    return isSameDate_(row['建議完成日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
  }).length;
  const overdueTasks = taskRows.filter(function(row) {
    return calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
  }).length;
  const incompleteTasks = taskRows.filter(function(row) {
    return row['狀態'] && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
  }).length;
  const highRiskSessions = sessionRows.filter(function(row) {
    return row['風險等級'] === '高' || row['風險等級'] === '極高';
  }).length;
  const pendingDocuments = documentRows.filter(function(row) {
    return row['承辦人確認'] !== '已確認' && row['承辦人確認'] !== '不適用';
  }).length;
  const notebookCount = activityRows.filter(function(row) { return row['NotebookLM資料包連結']; }).length;
  const importantTasks = taskOverviewRows.filter(function(row) { return row['重要行事'] === '是'; });
  const syncedCalendarTasks = importantTasks.filter(function(row) {
    const status = String(row['日曆同步狀態'] || '');
    return status === '已同步' || status === '已更新' || status === '已重新建立';
  }).length;
  const calendarReadSuccess = importantTasks.filter(function(row) {
    return String(row['日曆回寫狀態'] || '').indexOf('已回寫') === 0;
  }).length;
  const calendarMissing = importantTasks.filter(function(row) {
    return String(row['日曆回寫狀態'] || '') === '日曆事件遺失';
  }).length;
  const calendarFailed = importantTasks.filter(function(row) {
    return String(row['日曆同步狀態'] || '').indexOf('同步失敗') === 0
      || String(row['日曆回寫狀態'] || '').indexOf('回寫失敗') === 0;
  }).length;
  const todayImportant = importantTasks.filter(function(row) {
    return isSameDate_(row['到期日'] || row['日曆日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
  }).length;
  const delayedImportant = importantTasks.filter(function(row) {
    return row['狀態'] === '延後'
      || calculateDateStatus_(row['到期日'] || row['日曆日期'], row['狀態'] === '已完成') === '逾期';
  }).length;
  dashboard.getRange('B4:B23').setValues([
    [activityRows.length],
    [multiDay],
    [sessionRows.length],
    [registrationRows.length],
    [attendanceRows.length],
    [formatPercent_(checkedIn, attendanceRows.length)],
    [incompleteTasks],
    [todayDueTasks],
    [overdueTasks],
    [highRiskSessions],
    [pendingDocuments],
    [notebookCount],
    [nowText_()],
    [importantTasks.length],
    [syncedCalendarTasks],
    [calendarReadSuccess],
    [calendarMissing],
    [calendarFailed],
    [todayImportant],
    [delayedImportant]
  ]);
}

function updateDashboardStatusLights_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  ensureDashboardVisualLayout_(dashboard);

  const metricRows = dashboard.getRange('A4:B23').getValues();
  const attendanceCount = Number(dashboard.getRange('B8').getValue() || 0);
  const statusRows = [];
  const statusBackgrounds = [];

  metricRows.forEach(function(row) {
    const metric = String(row[0] || '');
    const value = row[1];
    const result = buildDashboardStatusLight_(metric, value, attendanceCount);
    statusRows.push([result.status, result.hint]);
    statusBackgrounds.push([result.color, result.color]);
  });

  dashboard.getRange('C4:D23').setValues(statusRows).setBackgrounds(statusBackgrounds);
  dashboard.getRange('B4:B23').setHorizontalAlignment('right');
  dashboard.getRange('C4:C23').setHorizontalAlignment('center');
  dashboard.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}

function buildDashboardStatusLight_(metric, value, attendanceCount) {
  const number = Number(value || 0);
  const percent = parseDashboardPercent_(value);

  if (metric === '活動總數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有活動資料')
      : dashboardLight_('gray', '⚪ 尚無資料', '請先新增活動');
  }
  if (metric === '多日活動數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有多日活動')
      : dashboardLight_('gray', '⚪ 尚無資料', '目前沒有多日活動');
  }
  if (metric === '場次總數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有場次設定')
      : dashboardLight_('yellow', '🟡 注意', '請產生或更新場次設定');
  }
  if (metric === '報名總人數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已有資料', '已有報名資料')
      : dashboardLight_('yellow', '🟡 注意', '尚未同步報名資料');
  }
  if (metric === '場次簽到總筆數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已有資料', '已有簽到資料')
      : dashboardLight_('yellow', '🟡 注意', '尚未產生或同步簽到資料');
  }
  if (metric === '平均出席率') {
    if (!attendanceCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚未建立簽到統計');
    if (percent >= 80) return dashboardLight_('green', '🟢 正常', '出席狀況良好');
    if (percent >= 50) return dashboardLight_('yellow', '🟡 注意', '出席率偏低,建議檢查簽到狀態');
    return dashboardLight_('red', '🔴 異常', '請更新場次簽到狀態');
  }
  if (metric === '未完成任務數') {
    if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有未完成任務');
    if (number <= 5) return dashboardLight_('yellow', '🟡 注意', '仍有少量任務待處理');
    return dashboardLight_('red', '🔴 待處理', '未完成任務偏多,請查看任務總覽');
  }
  if (metric === '今日到期任務') {
    return number === 0
      ? dashboardLight_('green', '🟢 正常', '今日沒有到期任務')
      : dashboardLight_('yellow', '🟡 今日到期', '今日有任務需要完成');
  }
  if (metric === '延宕任務數') {
    if (number === 0) return dashboardLight_('green', '🟢 正常', '沒有延宕任務');
    if (number <= 3) return dashboardLight_('yellow', '🟡 注意', '有少量延宕任務');
    return dashboardLight_('red', '🔴 逾期', '延宕任務偏多,請優先處理');
  }
  if (metric === '高風險場次數') {
    return number === 0
      ? dashboardLight_('green', '🟢 無風險', '目前沒有高風險場次')
      : dashboardLight_('red', '🔴 風險偏高', '請檢查場次人數、簽到率或資料缺漏');
  }
  if (metric === '待確認文件數') {
    if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有待確認文件');
    if (number <= 5) return dashboardLight_('yellow', '🟡 待確認', '仍有文件需要確認');
    return dashboardLight_('red', '🔴 待處理', '待確認文件偏多,請整理行政文件');
  }
  if (metric === 'NotebookLM 匯出數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已匯出', '已有 NotebookLM 資料包')
      : dashboardLight_('yellow', '🟡 尚未匯出', '活動結案前請匯出資料包');
  }
  if (metric === '最後更新時間') {
    return value
      ? dashboardLight_('green', '🟢 已更新', '中控台已完成更新')
      : dashboardLight_('gray', '⚪ 尚無資料', '尚未更新中控台');
  }
  if (metric === '重要行事總數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有重要行事')
      : dashboardLight_('gray', '⚪ 尚無資料', '尚未建立重要行事');
  }
  if (metric === '已同步日曆數') {
    const importantCount = Number(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.DASHBOARD).getRange('B17').getValue() || 0);
    if (!importantCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚無重要行事');
    return number >= importantCount
      ? dashboardLight_('green', '🟢 正常', '重要行事已同步')
      : dashboardLight_('yellow', '🟡 注意', '仍有重要行事未同步');
  }
  if (metric === '日曆回寫成功數') {
    return number > 0
      ? dashboardLight_('green', '🟢 正常', '已有日曆回寫紀錄')
      : dashboardLight_('gray', '⚪ 尚無資料', '尚未從日曆回寫');
  }
  if (metric === '日曆事件遺失數') {
    return number > 0
      ? dashboardLight_('red', '🔴 待處理', '有日曆事件遺失')
      : dashboardLight_('green', '🟢 正常', '沒有日曆事件遺失');
  }
  if (metric === '日曆同步失敗數') {
    return number > 0
      ? dashboardLight_('red', '🔴 待處理', '有日曆同步或回寫失敗')
      : dashboardLight_('green', '🟢 正常', '沒有日曆同步失敗');
  }
  if (metric === '今日到期重要行事') {
    return number > 0
      ? dashboardLight_('yellow', '🟡 今日到期', '今日有重要行事')
      : dashboardLight_('green', '🟢 正常', '今日沒有到期重要行事');
  }
  if (metric === '延宕重要行事') {
    return number > 0
      ? dashboardLight_('red', '🔴 逾期', '有延宕重要行事')
      : dashboardLight_('green', '🟢 正常', '沒有延宕重要行事');
  }
  return dashboardLight_('gray', '⚪ 不適用', '此指標尚未定義燈號');
}

function dashboardLight_(level, status, hint) {
  const colors = {
    green: '#e8f5e9',
    yellow: '#fff8e1',
    red: '#ffebee',
    gray: '#f5f5f5'
  };
  return {
    status: status,
    hint: hint,
    color: colors[level] || colors.gray
  };
}

function parseDashboardPercent_(value) {
  if (typeof value === 'number') {
    return value <= 1 ? value * 100 : value;
  }
  const text = String(value || '').trim();
  if (!text) return 0;
  const numeric = Number(text.replace('%', ''));
  return isNaN(numeric) ? 0 : numeric;
}

function updateDashboardSessionOverview_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  dashboard.getRange('A27:K36').clearContent();
  const activityNames = buildActivityNameMap_();
  const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).slice(0, 10);
  const rows = sessions.map(function(row) {
    return [
      row['活動ID'],
      activityNames[row['活動ID']] || '',
      row['場次ID'],
      row['場次日期'],
      row['場次名稱'],
      row['報名人數'],
      row['簽到人數'],
      row['出席率'],
      row['課前任務完成率'],
      row['目前狀態'],
      row['風險等級']
    ];
  });
  if (rows.length) dashboard.getRange(27, 1, rows.length, 11).setValues(rows);
}

function updateDashboardRedTasks_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  dashboard.getRange('A40:G51').clearContent();
  const tasks = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
    return row['狀態'] === '延後'
      || row['優先程度'] === '高' && row['狀態'] !== '已完成'
      || calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
  }).slice(0, 12);
  const rows = tasks.map(function(row) {
    return [row['活動ID'], row['場次ID'], row['任務名稱'], row['負責人'], row['建議完成日期'], row['狀態'], row['卡關原因']];
  });
  if (rows.length) dashboard.getRange(40, 1, rows.length, 7).setValues(rows);
}

function updateDashboardTodaySessions_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  dashboard.getRange('A55:G66').clearContent();
  const activityNames = buildActivityNameMap_();
  const today = new Date();
  const tomorrow = addDays_(today, 1);
  const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).filter(function(row) {
    return isSameDate_(row['場次日期'], today) || isSameDate_(row['場次日期'], tomorrow);
  }).slice(0, 12);
  const rows = sessions.map(function(row) {
    return [
      row['場次日期'],
      activityNames[row['活動ID']] || '',
      row['場次ID'],
      row['場次名稱'],
      valueOrPending_(row['開始時間']) + '-' + valueOrPending_(row['結束時間']),
      row['地點或線上連結'],
      row['目前狀態']
    ];
  });
  if (rows.length) dashboard.getRange(55, 1, rows.length, 7).setValues(rows);
}

function buildActivityNameMap_() {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
  const map = {};
  rows.forEach(function(row) {
    if (row['活動ID']) map[row['活動ID']] = row['活動名稱'];
  });
  return map;
}

function buildSessionNameMap_() {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
  const map = {};
  rows.forEach(function(row) {
    const activityId = row['活動ID'];
    const sessionId = row['場次ID'];
    if (activityId && sessionId) {
      map[activityId + '::' + sessionId] = row['場次名稱'] || '';
    }
  });
  return map;
}


/**
 * ============================================================
 * AttendanceFormService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:場次簽到表單與簽到回應同步。
 */
function createAttendanceFormsForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  const result = createAttendanceFormsForActivityContext_(context);
  updateDashboard_();
  alert_([
    '場次簽到表單處理完成。',
    '新建立:' + result.created + ' 張',
    '已存在略過:' + result.skipped + ' 張',
    '',
    '簽到表單連結已回填到 02_場次設定。'
  ].join('\n'));
}

function createAttendanceFormsForActivityContext_(context) {
  let activity = context.activity;

  if (!activity.id) {
    throw new Error('此活動缺少活動ID,請先建立活動資料包。');
  }

  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  activity = context.activity;

  const attendanceFolder = packageContext.subfolders['01_報名表單與回覆'] || getCampRootFolder_();
  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) {
    sessions = generateSessionsForActivity_(activity);
  }
  if (!sessions.length) {
    throw new Error('此活動尚未建立場次,無法產生簽到表單。');
  }

  const ss = getActiveSpreadsheet_();
  const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  const sessionMap = ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
  let created = 0;
  let skipped = 0;

  sessions.forEach(function(session) {
    const row = findSessionRow_(sessionSheet, sessionMap, activity.id, session.sessionId);
    if (!row) return;

    const existingUrl = String(readCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結') || '').trim();
    if (existingUrl) {
      skipped++;
      return;
    }

    const result = createAttendanceFormForSession_(ss, activity, session, attendanceFolder);
    writeCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結', result.prefilledUrl);
    writeCellByHeader_(sessionSheet, row, sessionMap, '目前狀態', '準備中');

    appendDocumentListRow_(
      activity,
      '簽到表單',
      result.title,
      '本場研習或活動簽到使用',
      result.prefilledUrl,
      'Google Form',
      '待使用',
      '待確認',
      '編輯連結:' + result.editUrl + '\nraw 回應分頁:' + result.responseSheetName
    );
    created++;
  });

  return { created: created, skipped: skipped };
}

function createAttendanceFormForSession_(ss, activity, session, folder) {
  const title = [
    '簽到表單',
    activity.id,
    session.sessionId,
    sanitizeFileName_(activity.name)
  ].join('_');
  const beforeSheetIds = getSheetIds_(ss);
  const form = FormApp.create(title);

  form.setDescription([
    '活動名稱:' + activity.name,
    '場次:' + session.sessionId + '|' + valueOrPending_(session.title),
    '日期:' + formatDateValue_(session.date),
    '時間:' + valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
    '地點或連結:' + valueOrPending_(session.location),
    '',
    '本表單已自動帶入活動ID、場次ID與場次名稱。',
    '請直接填寫姓名、單位與 Email 完成簽到。'
  ].join('\n'));
  form.setCollectEmail(false);
  form.setAllowResponseEdits(false);

  form.addTextItem()
    .setTitle('姓名')
    .setRequired(true);
  form.addTextItem()
    .setTitle('單位')
    .setRequired(true)
    .setHelpText('請填寫學校、服務單位或所屬機關。');
  form.addTextItem()
    .setTitle('Email')
    .setRequired(false)
    .setHelpText('選填。若需研習時數或後續通知,建議填寫。');
  form.addMultipleChoiceItem()
    .setTitle('是否本人簽到')
    .setChoiceValues(['本人簽到', '工作人員協助登記'])
    .setRequired(true);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);

  const activityIdItem = form.addTextItem()
    .setTitle('活動ID')
    .setRequired(true)
    .setHelpText('系統已自動帶入,請勿修改。');
  const sessionIdItem = form.addTextItem()
    .setTitle('場次ID')
    .setRequired(true)
    .setHelpText('系統已自動帶入,請勿修改。');
  const sessionNameItem = form.addTextItem()
    .setTitle('場次名稱')
    .setRequired(true)
    .setHelpText('系統已自動帶入,請勿修改。');

  form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
  Utilities.sleep(1200);

  const responseSheet = findNewSheetAfterFormDestination_(ss, beforeSheetIds);
  const responseSheetName = renameSheetSafely_(
    responseSheet,
    'raw_簽到回應_' + activity.id + '_' + session.sessionId
  );
  if (responseSheet) {
    try {
      responseSheet.hideSheet();
    } catch (error) {
      Logger.log('隱藏簽到 raw 分頁失敗:' + error.message);
    }
  }

  const prefilledUrl = form.createResponse()
    .withItemResponse(activityIdItem.createResponse(activity.id))
    .withItemResponse(sessionIdItem.createResponse(session.sessionId))
    .withItemResponse(sessionNameItem.createResponse(session.title || session.sessionId))
    .toPrefilledUrl();

  moveFileToFolder_(form.getId(), folder);
  return {
    form: form,
    title: title,
    prefilledUrl: prefilledUrl,
    editUrl: form.getEditUrl(),
    responseSheetName: responseSheetName
  };
}

function syncAttendanceFormResponses() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const attendanceSheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
  const attendanceMap = ensureHeaders_(attendanceSheet, CAMP_ATTENDANCE_HEADERS);
  let updated = 0;
  let added = 0;
  let skipped = 0;

  ss.getSheets().forEach(function(sheet) {
    if (sheet.getName().indexOf('raw_簽到回應_') !== 0) return;
    if (sheet.getLastRow() < 2) return;

    buildRowsByHeader_(sheet).forEach(function(row) {
      const result = syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, row, sheet.getName());
      if (result === 'updated') updated++;
      else if (result === 'added') added++;
      else skipped++;
    });
  });

  updateSessionStats_();
  updateDashboard_();
  alert_([
    '簽到狀態更新完成。',
    '已更新既有名冊:' + updated + ' 筆',
    '新增未在報名名單者:' + added + ' 筆',
    '略過:' + skipped + ' 筆'
  ].join('\n'));
}

function syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, response, sourceSheetName) {
  const activityId = String(response['活動ID'] || '').trim();
  const sessionId = String(response['場次ID'] || '').trim();
  const sessionName = String(response['場次名稱'] || '').trim();
  const name = String(response['姓名'] || '').trim();
  const unit = String(response['單位'] || '').trim();
  const email = String(response['Email'] || '').trim();
  const timestamp = response['時間戳記'] || response['Timestamp'] || new Date();
  const note = String(response['備註'] || '').trim();

  if (!activityId || !sessionId || !name) return 'skipped';

  const existingRow = findAttendanceRowForResponse_(
    attendanceSheet,
    attendanceMap,
    activityId,
    sessionId,
    name,
    unit,
    email
  );

  if (existingRow) {
    const currentStatus = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態') || '');
    if (currentStatus === '已簽到') return 'skipped';

    writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態', '已簽到');
    writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到時間', timestamp);

    const oldNote = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註') || '').trim();
    const newNote = [
      oldNote,
      note ? '簽到備註:' + note : '',
      '簽到來源:' + sourceSheetName
    ].filter(Boolean).join('\n');
    writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註', newNote);
    return 'updated';
  }

  attendanceSheet.appendRow([
    activityId,
    sessionId,
    sessionName,
    '',
    name,
    unit,
    email,
    buildRegistrationKey_({
      activityId: activityId,
      name: name,
      unit: unit,
      email: email
    }, sessionId),
    '否',
    '已簽到',
    timestamp,
    '未簽退',
    '',
    '',
    [
      '未在報名名單中,請人工確認。',
      note ? '簽到備註:' + note : '',
      '簽到來源:' + sourceSheetName
    ].filter(Boolean).join('\n')
  ]);
  return 'added';
}

function findAttendanceRowForResponse_(sheet, map, activityId, sessionId, name, unit, email) {
  let fallbackByName = 0;

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
    const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
    if (rowActivityId !== String(activityId) || rowSessionId !== String(sessionId)) continue;

    const rowName = String(readCellByHeader_(sheet, row, map, '學生或學員姓名') || '').trim();
    const rowUnit = String(readCellByHeader_(sheet, row, map, '學校或單位') || '').trim();
    const rowEmail = String(readCellByHeader_(sheet, row, map, 'Email') || '').trim();

    if (email && rowEmail && rowEmail === email) return row;
    if (rowName === name && rowUnit === unit) return row;
    if (!fallbackByName && rowName === name) fallbackByName = row;
  }

  return fallbackByName;
}

function findSessionRow_(sheet, headerMap, activityId, sessionId) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const rowActivityId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
    const rowSessionId = String(readCellByHeader_(sheet, row, headerMap, '場次ID') || '').trim();
    if (rowActivityId === String(activityId) && rowSessionId === String(sessionId)) return row;
  }
  return 0;
}

function getSheetIds_(ss) {
  const ids = {};
  ss.getSheets().forEach(function(sheet) {
    ids[sheet.getSheetId()] = true;
  });
  return ids;
}

function findNewSheetAfterFormDestination_(ss, beforeSheetIds) {
  const sheets = ss.getSheets();
  for (let i = 0; i < sheets.length; i++) {
    if (!beforeSheetIds[sheets[i].getSheetId()]) return sheets[i];
  }
  return null;
}

function renameSheetSafely_(sheet, desiredName) {
  if (!sheet) return '';
  const ss = sheet.getParent();
  let finalName = desiredName;
  let index = 2;

  while (ss.getSheetByName(finalName)) {
    finalName = desiredName + '_' + index;
    index++;
  }

  sheet.setName(finalName);
  return finalName;
}


/**
 * ============================================================
 * TaskOverviewService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:任務總覽。
 *
 * 將 05_任務追蹤 的 WBS 明細整理成承辦人每日檢視用的 05_任務總覽。
 */
function refreshTaskOverview() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);

  const overviewSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
  const existingOverviewState = buildExistingTaskOverviewState_(overviewSheet);
  overviewSheet.clear();
  setupTaskOverviewSheet_(overviewSheet);

  const activityNameMap = buildActivityNameMap_();
  const sessionNameMap = buildSessionNameMap_();
  const taskSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
  ensureHeaders_(taskSheet, CAMP_TASK_HEADERS);

  const overviewRows = buildRowsByHeader_(taskSheet)
    .filter(shouldIncludeTaskInOverview_)
    .map(function(row) {
      const activityId = row['活動ID'];
      const sessionId = row['場次ID'] || '';
      const taskId = buildTaskOverviewId_(row);
      const preserved = existingOverviewState[taskId] || {};
      const deadline = row['建議完成日期'] || '';
      const completion = row['實際完成日期'] || '';
      const task = {
        '任務ID': taskId,
        '活動ID': activityId,
        '活動名稱': activityNameMap[activityId] || '',
        '場次ID': sessionId,
        '場次名稱': sessionNameMap[activityId + '::' + sessionId] || '',
        '階段': row['階段'] || '',
        '任務名稱': row['任務名稱'] || '',
        '任務類型': row['階段'] || '',
        '負責人': row['負責人'] || '',
        '截止日': deadline,
        '到期日': deadline,
        '狀態': row['狀態'] || '未開始',
        '優先程度': row['優先程度'] || '中',
        '卡關原因/下一步': buildNextStepText_(row),
        '備註': row['備註'] || row['卡關原因'] || '',
        '重要行事': preserved['重要行事'] || (row['是否關鍵任務'] === '是' ? '是' : ''),
        '日曆標題': preserved['日曆標題'] || '',
        '日曆日期': preserved['日曆日期'] || deadline,
        '開始時間': preserved['開始時間'] || '',
        '結束時間': preserved['結束時間'] || '',
        '日曆事件ID': preserved['日曆事件ID'] || '',
        '日曆同步狀態': preserved['日曆同步狀態'] || '未同步',
        '最後同步時間': preserved['最後同步時間'] || '',
        '日曆回寫狀態': preserved['日曆回寫狀態'] || '未讀取',
        '日曆最後讀取時間': preserved['日曆最後讀取時間'] || '',
        '完成時間': completion,
        '完成日': completion,
        '最後更新時間': row['最後更新時間'] || ''
      };
      return CAMP_TASK_OVERVIEW_HEADERS.map(function(header) {
        return task[header] || '';
      });
    });

  if (overviewRows.length > 0) {
    overviewSheet.getRange(2, 1, overviewRows.length, CAMP_TASK_OVERVIEW_HEADERS.length)
      .setValues(overviewRows);
  }

  applyTaskOverviewFormatting_(overviewSheet);
  syncImportantTasksToCalendar_();
  moveTaskOverviewNearTaskSheet_();
  updateDashboard_();
  alert_('任務總覽已更新,共 ' + overviewRows.length + ' 筆。');
}

function buildExistingTaskOverviewState_(sheet) {
  if (!sheet || sheet.getLastRow() <= 1) return {};
  const map = buildHeaderMap_(sheet);
  const stateHeaders = [
    '重要行事',
    '日曆標題',
    '日曆日期',
    '開始時間',
    '結束時間',
    '日曆事件ID',
    '日曆同步狀態',
    '最後同步時間',
    '日曆回寫狀態',
    '日曆最後讀取時間'
  ];
  const state = {};
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const taskId = String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim();
    if (!taskId) continue;
    state[taskId] = {};
    stateHeaders.forEach(function(header) {
      state[taskId][header] = readCellByHeader_(sheet, row, map, header);
    });
  }
  return state;
}

function buildTaskOverviewId_(row) {
  const parts = [
    row['活動ID'],
    row['場次ID'] || 'ALL',
    row['WBS編碼'] || row['階段'] || '',
    row['任務名稱'] || ''
  ];
  return 'TASK-' + parts.join('-').replace(/[^\w\u4e00-\u9fa5-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}

function shouldIncludeTaskInOverview_(row) {
  const status = String(row['狀態'] || '');
  const priority = String(row['優先程度'] || '');
  const isKey = String(row['是否關鍵任務'] || '');
  const taskName = String(row['任務名稱'] || '');
  const phase = String(row['階段'] || '');

  if (status === '不適用') {
    return false;
  }

  if (status === '已完成') return true;
  if (priority === '高') return true;
  if (isKey === '是') return true;
  if (status === '進行中' || status === '待確認' || status === '延後') return true;

  const keywords = ['提醒', '簽到', '回饋', '成果', '講義', '連結', '通知', '名冊'];
  return keywords.some(function(keyword) {
    return taskName.indexOf(keyword) >= 0 || phase.indexOf(keyword) >= 0;
  });
}

function buildNextStepText_(row) {
  const stuck = String(row['卡關原因'] || '').trim();
  if (stuck) return stuck;

  const status = String(row['狀態'] || '');
  const taskName = String(row['任務名稱'] || '');
  const documentNeed = String(row['需要文件或表單'] || '');

  if (status === '待確認') {
    return '請承辦人或權責單位確認。';
  }

  if (status === '延後') {
    return '請確認延後原因與新的完成日期。';
  }

  if (documentNeed) {
    return '請確認或補齊:' + documentNeed;
  }

  if (taskName.indexOf('提醒') >= 0) {
    return '請確認提醒內容與發送對象。';
  }

  if (taskName.indexOf('簽到') >= 0) {
    return '請確認簽到方式、名單與時數需求。';
  }

  if (taskName.indexOf('成果') >= 0 || taskName.indexOf('回饋') >= 0) {
    return '請整理回饋、照片、講義或成果摘要。';
  }

  return '請依任務內容處理。';
}

function formatTaskOverviewHeader_(sheet) {
  sheet.getRange(1, 1, 1, CAMP_TASK_OVERVIEW_HEADERS.length)
    .setFontWeight('bold')
    .setBackground('#1e3a8a')
    .setFontColor('#ffffff')
    .setHorizontalAlignment('center')
    .setVerticalAlignment('middle')
    .setWrap(true);

  sheet.setFrozenRows(1);
  sheet.setFrozenColumns(5);
}

function applyTaskOverviewFormatting_(sheet) {
  const lastRow = sheet.getLastRow();
  const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
  const existingFilter = sheet.getFilter();
  if (existingFilter) existingFilter.remove();

  if (lastRow < 2) {
    sheet.autoResizeColumns(1, lastColumn);
    return;
  }

  sheet.getRange(2, 1, lastRow - 1, lastColumn)
    .setVerticalAlignment('middle')
    .setWrap(true);

  const statusCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1;
  const priorityCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1;
  const deadlineCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('到期日') + 1;
  const calendarSyncCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆同步狀態') + 1;
  const calendarReadCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆回寫狀態') + 1;

  for (let row = 2; row <= lastRow; row++) {
    const status = String(sheet.getRange(row, statusCol).getValue() || '');
    const priority = String(sheet.getRange(row, priorityCol).getValue() || '');
    const deadline = sheet.getRange(row, deadlineCol).getValue();
    const isDone = status === '已完成' || status === '不適用';
    const dateStatus = calculateDateStatus_(deadline, isDone);

    if (status === '已完成') {
      sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
    } else if (dateStatus === '逾期') {
      sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
    } else if (dateStatus === '即將到期') {
      sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
    } else {
      sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
    }

    if (CAMP_TASK_STATUS_COLORS[status]) {
      sheet.getRange(row, statusCol).setBackground(CAMP_TASK_STATUS_COLORS[status]);
    }

    if (CAMP_PRIORITY_COLORS[priority]) {
      sheet.getRange(row, priorityCol).setBackground(CAMP_PRIORITY_COLORS[priority]);
    }

    if (calendarSyncCol > 0) {
      sheet.getRange(row, calendarSyncCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarSyncCol).getValue()));
    }
    if (calendarReadCol > 0) {
      sheet.getRange(row, calendarReadCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarReadCol).getValue()));
    }
  }

  sheet.autoResizeColumns(1, lastColumn);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('活動名稱') + 1, 280);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('任務名稱') + 1, 220);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('卡關原因/下一步') + 1, 320);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('備註') + 1, 260);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆標題') + 1, 280);
  sheet.getRange(1, 1, lastRow, lastColumn).createFilter();
}

function moveTaskOverviewNearTaskSheet_() {
  const ss = getActiveSpreadsheet_();
  const sheet = ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW);
  if (!sheet) return;
  ss.setActiveSheet(sheet);
  ss.moveActiveSheet(CAMP_SHEET_ORDER.indexOf(CAMP_SHEETS.TASK_OVERVIEW) + 1);
}

function getCalendarStatusColor_(value) {
  const text = String(value || '');
  if (!text || text === '未讀取' || text === '尚無資料') return '#f5f5f5';
  if (text.indexOf('失敗') >= 0 || text.indexOf('遺失') >= 0) return '#ffebee';
  if (text.indexOf('需人工確認') >= 0 || text === '未同步' || text === '需重新同步') return '#fff8e1';
  if (text.indexOf('已同步') >= 0 || text.indexOf('已更新') >= 0 || text.indexOf('已回寫') >= 0 || text.indexOf('已重新建立') >= 0) return '#e8f5e9';
  return '#f5f5f5';
}

function handleTaskOverviewStatusEdit_(e) {
  if (!e || !e.range) return;
  const sheet = e.range.getSheet();
  if (sheet.getName() !== CAMP_SHEETS.TASK_OVERVIEW) return;
  if (e.range.getRow() <= 1 || e.range.getNumRows() !== 1 || e.range.getNumColumns() !== 1) return;

  const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  const statusCol = map['狀態'];
  if (!statusCol || e.range.getColumn() !== statusCol) return;

  const row = e.range.getRow();
  const newStatus = String(e.value || e.range.getValue() || '').trim();
  const oldStatus = String(e.oldValue || '').trim();
  const completionCol = map['完成時間'];
  const completionDayCol = map['完成日'];
  const updatedCol = map['最後更新時間'];
  const timestamp = nowMinuteText_();

  if (newStatus === '已完成') {
    const currentCompletion = completionCol ? String(sheet.getRange(row, completionCol).getValue() || '').trim() : '';
    if (completionCol && oldStatus !== '已完成' && !currentCompletion) {
      sheet.getRange(row, completionCol).setValue(timestamp);
    }
    const currentCompletionDay = completionDayCol ? String(sheet.getRange(row, completionDayCol).getValue() || '').trim() : '';
    if (completionDayCol && oldStatus !== '已完成' && !currentCompletionDay) {
      sheet.getRange(row, completionDayCol).setValue(timestamp);
    }
  }

  if (updatedCol) {
    sheet.getRange(row, updatedCol).setValue(timestamp);
  }

  applyTaskOverviewRowFormatting_(sheet, row);
  const task = readTaskOverviewRow_(sheet, row, map);
  syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp);
  if (task.activityId) {
    refreshTaskProgress_(task.activityId, task.sessionId);
  }
  updateDashboard_();
}

function readTaskOverviewRow_(sheet, row, map) {
  return {
    activityId: String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim(),
    activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || '').trim(),
    sessionId: String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim(),
    phase: String(readCellByHeader_(sheet, row, map, '階段') || '').trim(),
    taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
    owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
    deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
    status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
    completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
    lastUpdated: readCellByHeader_(sheet, row, map, '最後更新時間')
  };
}

function applyTaskOverviewRowFormatting_(sheet, row) {
  const map = buildHeaderMap_(sheet);
  const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
  const status = String(readCellByHeader_(sheet, row, map, '狀態') || '');
  const priority = String(readCellByHeader_(sheet, row, map, '優先程度') || '');
  const deadline = readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日');
  const dateStatus = calculateDateStatus_(deadline, status === '已完成' || status === '不適用');

  if (status === '已完成') {
    sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
  } else if (dateStatus === '逾期') {
    sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
  } else if (dateStatus === '即將到期') {
    sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
  } else {
    sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
  }

  if (CAMP_TASK_STATUS_COLORS[status] && map['狀態']) {
    sheet.getRange(row, map['狀態']).setBackground(CAMP_TASK_STATUS_COLORS[status]);
  }
  if (CAMP_PRIORITY_COLORS[priority] && map['優先程度']) {
    sheet.getRange(row, map['優先程度']).setBackground(CAMP_PRIORITY_COLORS[priority]);
  }
  if (map['日曆同步狀態']) {
    sheet.getRange(row, map['日曆同步狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆同步狀態')));
  }
  if (map['日曆回寫狀態']) {
    sheet.getRange(row, map['日曆回寫狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆回寫狀態')));
  }
}

function syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp) {
  if (!task.activityId || !task.taskName) return;
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
  const map = ensureHeaders_(sheet, CAMP_TASK_HEADERS);
  const row = findTaskRowForOverview_(sheet, map, task);
  if (!row) return;

  writeCellByHeader_(sheet, row, map, '狀態', newStatus);
  writeCellByHeader_(sheet, row, map, '最後更新時間', timestamp);

  if (newStatus === '已完成') {
    const currentDoneAt = String(readCellByHeader_(sheet, row, map, '實際完成日期') || '').trim();
    if (!currentDoneAt) {
      writeCellByHeader_(sheet, row, map, '實際完成日期', task.completionTime || timestamp);
    }
  }
}

function findTaskRowForOverview_(sheet, map, task) {
  let bestRow = 0;
  let bestScore = -1;
  const targetDeadline = normalizeTaskDateForCompare_(task.deadline);

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
    const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
    const phase = String(readCellByHeader_(sheet, row, map, '階段') || '').trim();
    const taskName = String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim();
    if (activityId !== task.activityId || sessionId !== task.sessionId || phase !== task.phase || taskName !== task.taskName) {
      continue;
    }

    let score = 4;
    const owner = String(readCellByHeader_(sheet, row, map, '負責人') || '').trim();
    const deadline = normalizeTaskDateForCompare_(readCellByHeader_(sheet, row, map, '建議完成日期'));
    if (task.owner && owner === task.owner) score++;
    if (targetDeadline && deadline === targetDeadline) score++;

    if (score > bestScore) {
      bestScore = score;
      bestRow = row;
    }
  }
  return bestRow;
}

function normalizeTaskDateForCompare_(value) {
  const date = parseDate_(value);
  return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd') : String(value || '').trim();
}

function refreshTaskProgress_(activityId, sessionId) {
  if (!activityId) return;
  const ss = getActiveSpreadsheet_();
  const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
    return String(row['活動ID'] || '') === String(activityId || '')
      && String(row['任務名稱'] || '').trim()
      && String(row['狀態'] || '') !== '不適用';
  });

  const total = taskRows.length;
  const done = taskRows.filter(function(row) {
    return String(row['狀態'] || '') === '已完成';
  }).length;
  const activityRate = formatPercent_(done, total);
  refreshActivityTaskProgress_(activityId, activityRate);

  if (sessionId && sessionId !== 'ALL' && sessionId !== 'POST') {
    const sessionRows = taskRows.filter(function(row) {
      return String(row['場次ID'] || '') === String(sessionId || '');
    });
    refreshSessionTaskProgress_(activityId, sessionId, sessionRows);
  }
}

function refreshActivityTaskProgress_(activityId, activityRate) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (String(readCellByHeader_(sheet, row, map, '活動ID') || '') !== String(activityId || '')) continue;
    writeCellByHeader_(sheet, row, map, '任務完成率', activityRate);
    writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
    return;
  }
}

function refreshSessionTaskProgress_(activityId, sessionId, sessionTaskRows) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  const map = ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  const total = sessionTaskRows.length;
  const done = sessionTaskRows.filter(function(row) {
    return String(row['狀態'] || '') === '已完成';
  }).length;
  const rate = formatPercent_(done, total);
  const status = total === 0 ? '無任務' : (done === total ? '已完成' : (done === 0 ? '未開始' : '進行中'));

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
    const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '');
    if (rowActivityId !== String(activityId || '') || rowSessionId !== String(sessionId || '')) continue;
    writeCellByHeader_(sheet, row, map, '任務完成率', rate);
    writeCellByHeader_(sheet, row, map, '任務完成狀態', status);
    return;
  }
}


/**
 * ============================================================
 * CalendarTaskService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:關鍵任務與 Google Calendar 同步。
 */
function syncImportantTasksToCalendar() {
  const result = syncImportantTasksToCalendar_();
  updateDashboard_();
  alert_('重要行事同步完成。建立:' + result.created + ',更新:' + result.updated + ',略過:' + result.skipped + ',失敗:' + result.failed + '。');
}

function syncCalendarBackToTaskOverview() {
  const result = syncTaskStatusFromCalendar_();
  updateDashboard_();
  alert_('已從 Google Calendar 同步任務狀態。回寫:' + result.updated + ',遺失:' + result.missing + ',失敗:' + result.failed + '。');
}

function createCalendarSyncTrigger() {
  createCalendarSyncTrigger_();
  alert_('已建立日曆同步觸發器:每 30 分鐘從 Google Calendar 回寫任務狀態。');
}

function removeCalendarSyncTrigger() {
  const removed = removeCalendarSyncTrigger_();
  alert_('已移除日曆同步觸發器:' + removed + ' 個。');
}

function syncImportantTasksToCalendar_() {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
  const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  const calendar = getTaskCalendar_();
  const result = { created: 0, updated: 0, skipped: 0, failed: 0 };

  if (!calendar) {
    writeCalendarSyncStatusForAll_(sheet, map, '同步失敗:找不到日曆');
    result.failed = Math.max(0, sheet.getLastRow() - 1);
    return result;
  }

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const task = readCalendarTaskFromRow_(sheet, row, map);
    if (!task.taskName) continue;

    const important = isImportantCalendarTask_(task);
    writeCellByHeader_(sheet, row, map, '重要行事', important ? '是' : '否');
    if (!important) {
      result.skipped++;
      continue;
    }

    const date = getTaskCalendarDate_(task);
    if (!date) {
      writeCellByHeader_(sheet, row, map, '日曆同步狀態', '同步失敗:缺少日期');
      result.failed++;
      continue;
    }

    try {
      const title = buildCalendarTitle_(task);
      const description = buildCalendarDescription_(task);
      const eventInfo = upsertTaskCalendarEvent_(calendar, task, title, description, date);
      writeCellByHeader_(sheet, row, map, '日曆標題', title);
      writeCellByHeader_(sheet, row, map, '日曆日期', date);
      writeCellByHeader_(sheet, row, map, '日曆事件ID', eventInfo.event.getId());
      writeCellByHeader_(sheet, row, map, '日曆同步狀態', eventInfo.status);
      writeCellByHeader_(sheet, row, map, '最後同步時間', nowText_());
      if (eventInfo.status === '已同步' || eventInfo.status === '已重新建立') result.created++;
      if (eventInfo.status === '已更新') result.updated++;
    } catch (error) {
      writeCellByHeader_(sheet, row, map, '日曆同步狀態', classifyCalendarError_(error, '同步失敗'));
      result.failed++;
    }
  }

  applyTaskOverviewFormatting_(sheet);
  return result;
}

function syncTaskStatusFromCalendar_() {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
  const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  const calendar = getTaskCalendar_();
  const result = { updated: 0, missing: 0, failed: 0 };

  if (!calendar) {
    writeCalendarReadStatusForAll_(sheet, map, '回寫失敗:其他錯誤');
    result.failed = Math.max(0, sheet.getLastRow() - 1);
    return result;
  }

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const task = readCalendarTaskFromRow_(sheet, row, map);
    if (!task.eventId) continue;

    try {
      const event = calendar.getEventById(task.eventId);
      if (!event) {
        writeCellByHeader_(sheet, row, map, '日曆回寫狀態', '日曆事件遺失');
        writeCellByHeader_(sheet, row, map, '日曆同步狀態', '需重新同步');
        writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
        result.missing++;
        continue;
      }

      const changes = [];
      const parsedStatus = parseTaskStatusFromCalendarEvent_(event);
      if (parsedStatus && parsedStatus !== task.status) {
        writeCellByHeader_(sheet, row, map, '狀態', parsedStatus);
        changes.push('狀態更新');
        if (parsedStatus === '已完成' && task.status !== '已完成') {
          if (!task.completionTime) writeCellByHeader_(sheet, row, map, '完成時間', nowMinuteText_());
          if (!task.completionDay) writeCellByHeader_(sheet, row, map, '完成日', nowMinuteText_());
        }
      }

      const eventStart = event.getStartTime();
      const eventEnd = event.getEndTime();
      if (eventStart) {
        writeCellByHeader_(sheet, row, map, '日曆日期', eventStart);
        writeCellByHeader_(sheet, row, map, '到期日', eventStart);
        changes.push('日期更新');
      }
      if (!event.isAllDayEvent()) {
        writeCellByHeader_(sheet, row, map, '開始時間', formatTaskTime_(eventStart));
        writeCellByHeader_(sheet, row, map, '結束時間', formatTaskTime_(eventEnd));
        changes.push('時間更新');
      }

      const note = parseCalendarField_(event.getDescription(), '任務備註');
      if (note) {
        writeCellByHeader_(sheet, row, map, '備註', note);
        changes.push('備註更新');
      }

      writeCellByHeader_(sheet, row, map, '日曆標題', event.getTitle());
      writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
      writeCellByHeader_(sheet, row, map, '日曆回寫狀態', changes.length ? '已回寫:' + changes.join('、') : '已回寫');
      applyTaskOverviewRowFormatting_(sheet, row);
      result.updated++;
    } catch (error) {
      writeCellByHeader_(sheet, row, map, '日曆回寫狀態', classifyCalendarError_(error, '回寫失敗'));
      result.failed++;
    }
  }

  return result;
}

function isImportantCalendarTask_(task) {
  if (!getTaskCalendarDate_(task)) return false;
  const text = [task.type, task.taskName, task.note].join(' ');
  const exclude = ['同步報名資料與統計', '更新場次統計', '更新任務總覽', '更新期限狀態', '一鍵更新場次簽到狀態', '產生 50 筆假資料', '系統初始化', '建立示範資料'];
  if (exclude.some(function(keyword) { return text.indexOf(keyword) >= 0; })) return false;
  const include = ['活動日期', '活動開始', '活動結束', '正式活動', '營隊日期', '研習日期', '報名開始', '報名截止', '錄取公告', '候補通知', '場次日期', '梯次日期', '課程日期', '研習場次', '活動場次', '行前通知', '寄送通知', '提醒信', '錄取通知', '簽到表單建立', '簽到準備', '簽到 QR Code', '簽到測試', '活動計畫', '行政文件', '成果報告', '成果檢查', '結案', '核銷', 'NotebookLM 匯出', '提醒', '通知', '簽到', '成果', '報名'];
  return task.isKey === '是' || include.some(function(keyword) { return text.indexOf(keyword) >= 0; });
}

function buildCalendarTitle_(task) {
  const text = [task.type, task.taskName].join(' ');
  let prefix = '【任務】';
  if (/活動|營隊|研習場次|正式/.test(text)) prefix = '【活動】';
  if (/報名截止|報名開始|錄取|候補/.test(text)) prefix = '【報名截止】';
  if (/通知|提醒信|行前/.test(text)) prefix = '【通知】';
  if (/簽到/.test(text)) prefix = '【簽到準備】';
  if (/文件|計畫|成果報告/.test(text)) prefix = '【文件】';
  if (/結案|核銷|NotebookLM/.test(text)) prefix = '【結案】';
  return prefix + valueOrPending_(task.activityName) + (task.sessionName ? '|' + task.sessionName : '|' + task.taskName);
}

function buildCalendarDescription_(task) {
  return [
    '此事件由營隊活動管理中樞自動建立。',
    '',
    '【系統資料】',
    '任務ID:' + task.taskId,
    '活動ID:' + task.activityId,
    task.sessionId ? '場次ID:' + task.sessionId : '',
    '',
    '【任務資料】',
    task.activityName ? '活動名稱:' + task.activityName : '',
    task.sessionName ? '場次名稱:' + task.sessionName : '',
    '任務名稱:' + task.taskName,
    task.type ? '任務類型:' + task.type : '',
    task.owner ? '負責人:' + task.owner : '',
    '任務狀態:' + valueOrPending_(task.status),
    task.deadline ? '到期日:' + formatDateValue_(task.deadline) : '',
    '',
    '【可由日曆修改】',
    '任務狀態:' + valueOrPending_(task.status),
    '任務備註:' + (task.note || ''),
    '',
    '【相關連結】',
    task.folderUrl ? '資料夾連結:' + task.folderUrl : '',
    task.registrationUrl ? '報名表連結:' + task.registrationUrl : '',
    task.attendanceUrl ? '簽到表連結:' + task.attendanceUrl : ''
  ].filter(function(line) { return line !== ''; }).join('\n');
}

function getTaskCalendarDate_(task) {
  return parseDate_(task.calendarDate) || parseDate_(task.deadline) || parseDate_(task.fallbackDate);
}

function parseTaskStatusFromCalendarEvent_(event) {
  const description = event.getDescription() || '';
  const fromDescription = parseCalendarField_(description, '任務狀態');
  return normalizeCalendarTaskStatus_(fromDescription) || parseCalendarStatusFromTitle_(event.getTitle());
}

function createCalendarSyncTrigger_() {
  removeCalendarSyncTrigger_();
  ScriptApp.newTrigger('syncCalendarBackToTaskOverview')
    .timeBased()
    .everyMinutes(30)
    .create();
}

function removeCalendarSyncTrigger_() {
  let removed = 0;
  ScriptApp.getProjectTriggers().forEach(function(trigger) {
    if (trigger.getHandlerFunction() === 'syncCalendarBackToTaskOverview') {
      ScriptApp.deleteTrigger(trigger);
      removed++;
    }
  });
  return removed;
}

function getTaskCalendar_() {
  try {
    if (CAMP_CONFIG.CALENDAR_ID) {
      return CalendarApp.getCalendarById(CAMP_CONFIG.CALENDAR_ID);
    }
    return CalendarApp.getDefaultCalendar();
  } catch (error) {
    return null;
  }
}

function upsertTaskCalendarEvent_(calendar, task, title, description, date) {
  let event = task.eventId ? calendar.getEventById(task.eventId) : null;
  let status = '已更新';
  if (!event) {
    event = createTaskCalendarEvent_(calendar, task, title, description, date);
    return { event: event, status: task.eventId ? '已重新建立' : '已同步' };
  }
  event.setTitle(title);
  event.setDescription(description);
  applyTaskCalendarEventTime_(event, task, date);
  return { event: event, status: status };
}

function createTaskCalendarEvent_(calendar, task, title, description, date) {
  const start = buildTaskDateTime_(date, task.startTime);
  const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
  if (task.startTime) {
    return calendar.createEvent(title, start, end, { description: description });
  }
  return calendar.createAllDayEvent(title, date, { description: description });
}

function applyTaskCalendarEventTime_(event, task, date) {
  if (task.startTime) {
    const start = buildTaskDateTime_(date, task.startTime);
    const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
    event.setTime(start, end);
  } else {
    event.setAllDayDate(date);
  }
}

function readCalendarTaskFromRow_(sheet, row, map) {
  const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
  const activity = getActivityCalendarContext_(activityId);
  const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
  const session = getSessionCalendarContext_(activityId, sessionId);
  return {
    row: row,
    taskId: String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim(),
    activityId: activityId,
    activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || activity.name || '').trim(),
    sessionId: sessionId,
    sessionName: String(readCellByHeader_(sheet, row, map, '場次名稱') || session.name || '').trim(),
    type: String(readCellByHeader_(sheet, row, map, '任務類型') || readCellByHeader_(sheet, row, map, '階段') || '').trim(),
    taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
    owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
    status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
    deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
    completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
    completionDay: readCellByHeader_(sheet, row, map, '完成日'),
    note: String(readCellByHeader_(sheet, row, map, '備註') || readCellByHeader_(sheet, row, map, '卡關原因/下一步') || '').trim(),
    isKey: String(readCellByHeader_(sheet, row, map, '重要行事') || '').trim(),
    calendarTitle: String(readCellByHeader_(sheet, row, map, '日曆標題') || '').trim(),
    calendarDate: readCellByHeader_(sheet, row, map, '日曆日期'),
    startTime: String(readCellByHeader_(sheet, row, map, '開始時間') || '').trim(),
    endTime: String(readCellByHeader_(sheet, row, map, '結束時間') || '').trim(),
    eventId: String(readCellByHeader_(sheet, row, map, '日曆事件ID') || '').trim(),
    fallbackDate: session.date || activity.date,
    folderUrl: activity.folderUrl,
    registrationUrl: activity.registrationUrl,
    attendanceUrl: session.attendanceUrl
  };
}

function getActivityCalendarContext_(activityId) {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
  const row = rows.filter(function(item) { return String(item['活動ID'] || '') === String(activityId || ''); })[0] || {};
  return {
    name: row['活動名稱'] || '',
    date: row['活動日期'] || row['活動開始日'],
    folderUrl: row['專案資料夾連結'] || '',
    registrationUrl: row['報名表單連結'] || ''
  };
}

function getSessionCalendarContext_(activityId, sessionId) {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
  const row = rows.filter(function(item) {
    return String(item['活動ID'] || '') === String(activityId || '') && String(item['場次ID'] || '') === String(sessionId || '');
  })[0] || {};
  return {
    name: row['場次名稱'] || '',
    date: row['場次日期'],
    attendanceUrl: row['簽到表單連結'] || ''
  };
}

function buildTaskDateTime_(date, timeText) {
  const base = parseDate_(date);
  const parts = String(timeText || '09:00').split(':');
  return new Date(base.getFullYear(), base.getMonth(), base.getDate(), Number(parts[0] || 9), Number(parts[1] || 0), 0);
}

function addHours_(date, hours) {
  return new Date(date.getTime() + Number(hours || 1) * 60 * 60 * 1000);
}

function formatTaskTime_(date) {
  return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'HH:mm') : '';
}

function parseCalendarField_(description, label) {
  const lines = String(description || '').split(/\r?\n/);
  for (let i = lines.length - 1; i >= 0; i--) {
    const line = lines[i];
    if (line.indexOf(label + ':') === 0) return line.replace(label + ':', '').trim();
  }
  return '';
}

function parseCalendarStatusFromTitle_(title) {
  const match = String(title || '').match(/^\[(完成|已完成|進行中|待確認|延宕|延後|取消|不適用)\]/);
  return match ? normalizeCalendarTaskStatus_(match[1]) : '';
}

function normalizeCalendarTaskStatus_(value) {
  const text = String(value || '').trim().toLowerCase();
  if (!text) return '';
  if (text === '完成' || text === '已完成' || text === 'done' || text === 'ok') return '已完成';
  if (text === '進行中') return '進行中';
  if (text === '待確認') return '待確認';
  if (text === '延宕' || text === '延後' || text === 'delay' || text === 'delayed') return '延後';
  if (text === '取消' || text === 'cancel') return '取消';
  if (text === '不適用') return '不適用';
  if (CAMP_TASK_STATUS_VALUES.indexOf(value) >= 0) return value;
  return '';
}

function classifyCalendarError_(error, prefix) {
  const message = String(error && error.message || '');
  if (message.indexOf('Authorization') >= 0 || message.indexOf('permission') >= 0 || message.indexOf('權限') >= 0) {
    return prefix + ':權限不足';
  }
  return prefix + ':其他錯誤';
}

function writeCalendarSyncStatusForAll_(sheet, map, status) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    writeCellByHeader_(sheet, row, map, '日曆同步狀態', status);
  }
}

function writeCalendarReadStatusForAll_(sheet, map, status) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    writeCellByHeader_(sheet, row, map, '日曆回寫狀態', status);
  }
}


/**
 * ============================================================
 * ActivityIntakeForm.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:活動新增表單。
 *
 * Google Form 的回應會寫入同一份 Spreadsheet,再由同步函式整理到
 * 「01_活動清單」。這樣承辦人可以用表單新增活動,後續仍以活動清單
 * 作為單一管理入口。
 */
function createActivityIntakeForm() {
  const result = createActivityIntakeForm_();
  alert_('活動新增表單已建立或確認存在:\n' + result.formUrl + '\n\n表單回應後,請執行「使用表單新增活動時才按:匯入活動回應」。若已使用新北示範資料,則不需要再匯入活動表單回應。');
  return result.formUrl;
}

function createActivityIntakeForm_() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const props = PropertiesService.getScriptProperties();
  const existingFormId = props.getProperty('CAMP_ACTIVITY_INTAKE_FORM_ID');
  if (existingFormId) {
    try {
      const form = FormApp.openById(existingFormId);
      ensureActivityIntakeFormItems_(form);
      form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
      writeActivityIntakeFormLinks_(ss, form);
      ensureActivityIntakeSubmitTrigger_(ss);
      return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
    } catch (error) {
      Logger.log('既有活動新增表單無法開啟,將重新建立:' + error.message);
    }
  }

  const beforeSheetIds = ss.getSheets().map(function(sheet) {
    return sheet.getSheetId();
  });

  const form = FormApp.create('營隊/活動新增表單');
  form.setDescription([
    '請填寫一筆營隊或活動資料。',
    '表單回應會寫入本中控 Spreadsheet,並可同步到「01_活動清單」。',
    '',
    CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
  ].join('\n'));
  form.addTextItem().setTitle('活動名稱').setRequired(true);
  form.addListItem().setTitle('活動類型').setChoiceValues(['營隊', '校外教學', '校內活動', '研習', '競賽', '成果發表', '其他']).setRequired(true);
  form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
  form.addDateItem().setTitle('活動日期').setHelpText('若是單日活動可填此欄;若是多日活動,請改填活動開始日與活動結束日。').setRequired(false);
  form.addDateItem().setTitle('活動開始日').setRequired(false);
  form.addDateItem().setTitle('活動結束日').setRequired(false);
  form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
  form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
  form.addDateItem().setTitle('報名截止日').setRequired(false);
  form.addDateItem().setTitle('同意書回收日').setRequired(false);
  form.addTextItem().setTitle('活動地點').setRequired(false);
  form.addTextItem().setTitle('參加對象').setRequired(false);
  form.addTextItem().setTitle('預估人數').setRequired(false);
  form.addTextItem().setTitle('承辦處室').setRequired(false);
  form.addTextItem().setTitle('承辦人').setRequired(false);
  form.addTextItem().setTitle('承辦人Email').setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否收費').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addParagraphTextItem().setTitle('費用說明').setRequired(false);
  form.addListItem().setTitle('交通方式').setChoiceValues(['校內活動無交通', '步行', '遊覽車', '大眾運輸', '家長接送', '其他或待確認']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要保險').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要家長同意書').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);
  form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
  Utilities.sleep(1000);

  const responseSheet = resolveActivityIntakeResponseSheet_(ss, beforeSheetIds);
  props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_ID', form.getId());
  props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_URL', form.getPublishedUrl());
  props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_EDIT_URL', form.getEditUrl());
  props.setProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME', responseSheet.getName());

  moveFileToFolder_(form.getId(), getCampRootFolder_());
  writeActivityIntakeFormLinks_(ss, form);
  ensureActivityIntakeSubmitTrigger_(ss);
  return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
}

function syncActivityIntakeResponsesToActivityList() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const responseSheet = getActivityIntakeResponseSheet_(ss);
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
  const responseMap = ensureActivityIntakeSyncColumns_(responseSheet);
  const existingKeys = buildExistingActivityIntakeKeys_(activitySheet, activityMap);
  let added = 0;
  let skipped = 0;

  for (let row = 2; row <= responseSheet.getLastRow(); row++) {
    const currentStatus = String(readCellByHeader_(responseSheet, row, responseMap, '同步狀態') || '').trim();
    if (currentStatus && currentStatus !== '同步失敗') {
      skipped++;
      continue;
    }

    const timestamp = readCellByHeader_(responseSheet, row, responseMap, '時間戳記') || readCellByHeader_(responseSheet, row, responseMap, 'Timestamp');
    const name = String(readCellByHeader_(responseSheet, row, responseMap, '活動名稱') || '').trim();
    if (!name) {
      writeActivityIntakeSyncNote_(responseSheet, row, responseMap, '已略過', '缺少活動名稱,未同步。');
      skipped++;
      continue;
    }
    const key = buildActivityIntakeKey_(name, timestamp);
    if (existingKeys[key]) {
      writeActivityIntakeSyncNote_(
        responseSheet,
        row,
        responseMap,
        '已略過:同一表單回應已同步',
        '活動名稱與表單時間戳記已存在於 01_活動清單備註欄。'
      );
      skipped++;
      continue;
    }

    const existingActivity = findExistingActivityByName_(activitySheet, activityMap, name);
    if (existingActivity) {
      writeActivityIntakeSyncNote_(
        responseSheet,
        row,
        responseMap,
        '已略過:同名活動已存在',
        '已存在活動ID:' + existingActivity.id + ';活動名稱:' + existingActivity.name + '。未新增第二筆。'
      );
      appendNoteToExistingActivity_(activitySheet, activityMap, existingActivity.row, [
        '活動新增表單有同名資料,系統已略過避免重複。',
        '表單時間戳記:' + formatDateIfNeeded_(timestamp),
        '略過時間:' + nowText_()
      ].join('\n'));
      skipped++;
      continue;
    }

    const activityId = generateActivityId_();
    const startDate = readCellByHeader_(responseSheet, row, responseMap, '活動開始日') || readCellByHeader_(responseSheet, row, responseMap, '活動日期');
    const endDate = readCellByHeader_(responseSheet, row, responseMap, '活動結束日') || startDate;
    const activityDraft = {
      id: activityId,
      activityDate: readCellByHeader_(responseSheet, row, responseMap, '活動日期'),
      startDate: startDate,
      endDate: endDate
    };
    const activityDays = calculateActivityDays_(activityDraft);
    const object = {};
    object['活動ID'] = activityId;
    object['活動名稱'] = name;
    object['活動類型'] = readCellByHeader_(responseSheet, row, responseMap, '活動類型');
    object['報名表模板類型'] = readCellByHeader_(responseSheet, row, responseMap, '報名表模板類型') || '自動判斷';
    object['活動日期'] = readCellByHeader_(responseSheet, row, responseMap, '活動日期');
    object['活動開始日'] = startDate;
    object['活動結束日'] = endDate;
    object['每日預設開始時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設開始時間') || '09:00';
    object['每日預設結束時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設結束時間') || '12:00';
    object['活動天數'] = activityDays;
    object['場次數'] = 0;
    object['是否多日活動'] = readCellByHeader_(responseSheet, row, responseMap, '是否多日活動') || (Number(activityDays || 0) > 1 ? '是' : '否');
    object['是否開放部分場次報名'] = readCellByHeader_(responseSheet, row, responseMap, '是否開放部分場次報名') || '否';
    object['是否需要每日簽到'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要每日簽到') || '是';
    object['是否需要課後回饋'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要課後回饋') || '是';
    object['是否需要成果繳交'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要成果繳交') || '是';
    object['報名截止日'] = readCellByHeader_(responseSheet, row, responseMap, '報名截止日');
    object['同意書回收日'] = readCellByHeader_(responseSheet, row, responseMap, '同意書回收日');
    object['活動地點'] = readCellByHeader_(responseSheet, row, responseMap, '活動地點');
    object['參加對象'] = readCellByHeader_(responseSheet, row, responseMap, '參加對象');
    object['預估人數'] = readCellByHeader_(responseSheet, row, responseMap, '預估人數');
    object['承辦處室'] = readCellByHeader_(responseSheet, row, responseMap, '承辦處室');
    object['承辦人'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人');
    object['承辦人Email'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人Email');
    object['是否收費'] = readCellByHeader_(responseSheet, row, responseMap, '是否收費');
    object['費用說明'] = readCellByHeader_(responseSheet, row, responseMap, '費用說明');
    object['交通方式'] = readCellByHeader_(responseSheet, row, responseMap, '交通方式');
    object['是否需要保險'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要保險');
    object['是否需要家長同意書'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要家長同意書');
    object['報名人數'] = 0;
    object['同意參加'] = 0;
    object['不參加'] = 0;
    object['特殊需求數'] = 0;
    object['目前狀態'] = '表單新增';
    object['最近更新時間'] = nowText_();
    object['備註'] = '由活動新增表單同步;表單時間戳記:' + formatDateIfNeeded_(timestamp);
    activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) { return object[header] || ''; }));
    writeActivityIntakeSyncNote_(
      responseSheet,
      row,
      responseMap,
      '已新增',
      '已新增到 01_活動清單,活動ID:' + activityId
    );
    const newRow = activitySheet.getLastRow();
    const activity = readActivityFromRow_(activitySheet, newRow, activityMap);
    generateSessionsForActivity_(activity);
    updateActivitySessionFields_({ sheet: activitySheet, row: newRow, headerMap: activityMap, activity: activity });
    existingKeys[key] = true;
    added++;
  }

  updateDashboard_();
  alert_('活動表單同步完成。新增到 01_活動清單:' + added + ' 筆;略過:' + skipped + ' 筆。');
  return added;
}

function writeActivityIntakeFormLinks_(ss, form) {
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
  ensureDashboardVisualLayout_(dashboard);
  dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
  dashboard.getRange('G4').setValue(form.getPublishedUrl()).setWrap(true);
  dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
  dashboard.getRange('G5').setValue(form.getEditUrl()).setWrap(true);
  dashboard.getRange('F6').setValue('操作提醒').setFontWeight('bold');
  dashboard.getRange('G6').setValue('如果已使用「新增示範資料:新北AI研習」,不需要再執行「匯入活動新增表單回應到活動清單」。').setWrap(true);

  const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
  ensureHeaders_(settings, CAMP_SETTINGS_HEADERS);
  upsertSettingRow_(settings, '活動新增表單', form.getPublishedUrl(), '承辦人填寫後可同步到 01_活動清單');
  upsertSettingRow_(settings, '活動新增表單編輯', form.getEditUrl(), '講師或管理者調整表單欄位使用');
}

function getActivityIntakeResponseSheet_(ss) {
  const savedName = PropertiesService.getScriptProperties().getProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME');
  if (savedName) {
    const sheet = ss.getSheetByName(savedName);
    if (sheet && looksLikeActivityIntakeResponseSheet_(sheet)) return sheet;
  }
  const sheets = ss.getSheets();
  for (let i = 0; i < sheets.length; i++) {
    if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
  }
  throw new Error('找不到活動新增表單回應分頁。請先執行「建立/開啟活動新增表單」,並送出至少一筆表單或確認表單已連結此試算表。');
}

function resolveActivityIntakeResponseSheet_(ss, beforeSheetIds) {
  const before = {};
  beforeSheetIds.forEach(function(id) {
    before[id] = true;
  });
  const sheets = ss.getSheets();
  for (let i = 0; i < sheets.length; i++) {
    if (!before[sheets[i].getSheetId()] && looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
  }
  for (let i = 0; i < sheets.length; i++) {
    if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
  }
  return sheets[sheets.length - 1];
}

function looksLikeActivityIntakeResponseSheet_(sheet) {
  if (sheet.getLastColumn() < 6) return false;
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
  return headers.indexOf('活動名稱') >= 0
    && headers.indexOf('活動類型') >= 0
    && headers.indexOf('承辦人Email') >= 0
    && headers.indexOf('是否需要家長同意書') >= 0;
}

function ensureActivityIntakeFormItems_(form) {
  const titles = {};
  form.getItems().forEach(function(item) {
    titles[item.getTitle()] = true;
  });
  if (!titles['活動開始日']) form.addDateItem().setTitle('活動開始日').setRequired(false);
  if (!titles['報名表模板類型']) form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
  if (!titles['活動結束日']) form.addDateItem().setTitle('活動結束日').setRequired(false);
  if (!titles['每日預設開始時間']) form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
  if (!titles['每日預設結束時間']) form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
  if (!titles['是否多日活動']) form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  if (!titles['是否開放部分場次報名']) form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
  if (!titles['是否需要每日簽到']) form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
  if (!titles['是否需要課後回饋']) form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
  if (!titles['是否需要成果繳交']) form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
}

function buildExistingActivityIntakeKeys_(activitySheet, activityMap) {
  const keys = {};
  for (let row = 2; row <= activitySheet.getLastRow(); row++) {
    const name = readCellByHeader_(activitySheet, row, activityMap, '活動名稱');
    const note = String(readCellByHeader_(activitySheet, row, activityMap, '備註') || '');
    const match = note.match(/表單時間戳記:([^\n]+)/);
    const timestamp = match ? match[1] : '';
    if (name && timestamp) {
      keys[buildActivityIntakeKey_(name, timestamp)] = true;
    }
  }
  return keys;
}

function buildActivityIntakeKey_(name, timestamp) {
  return String(name || '').trim() + '|' + String(timestamp || '').trim();
}

function upsertSettingRow_(sheet, key, value, note) {
  const map = ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (readCellByHeader_(sheet, row, map, '設定項目') === key) {
      writeCellByHeader_(sheet, row, map, '設定值', value);
      writeCellByHeader_(sheet, row, map, '說明', note);
      return;
    }
  }
  sheet.appendRow([key, value, note]);
}

function ensureActivityIntakeSubmitTrigger_(ss) {
  const functionName = 'syncActivityIntakeResponsesToActivityList';
  const exists = ScriptApp.getProjectTriggers().some(function(trigger) {
    return trigger.getHandlerFunction() === functionName
      && trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT;
  });
  if (!exists) {
    ScriptApp.newTrigger(functionName).forSpreadsheet(ss).onFormSubmit().create();
  }
}

function normalizeActivityName_(name) {
  return String(name || '')
    .trim()
    .toLowerCase()
    .replace(/[\s ]/g, '')
    .replace(/[||\--—–_::,,。.、//()()「」『』【】\[\]]/g, '');
}

function findExistingActivityByName_(sheet, headerMap, activityName) {
  const targetName = String(activityName || '').trim();
  const normalizedTarget = normalizeActivityName_(targetName);
  if (!targetName) return null;

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
    const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
    const existingStatus = String(readCellByHeader_(sheet, row, headerMap, '目前狀態') || '').trim();
    const existingNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();

    if (!existingName) continue;
    if (existingName === targetName || normalizeActivityName_(existingName) === normalizedTarget) {
      return {
        row: row,
        id: existingId,
        name: existingName,
        status: existingStatus,
        note: existingNote
      };
    }
  }
  return null;
}

function ensureActivityIntakeSyncColumns_(sheet) {
  const requiredHeaders = ['同步狀態', '同步時間', '同步備註'];
  let headers = sheet.getRange(1, 1, 1, Math.max(sheet.getLastColumn(), 1)).getValues()[0].map(String);

  requiredHeaders.forEach(function(header) {
    if (headers.indexOf(header) < 0) {
      sheet.getRange(1, sheet.getLastColumn() + 1).setValue(header);
      headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
    }
  });

  return buildHeaderMap_(sheet);
}

function writeActivityIntakeSyncNote_(sheet, row, responseMap, status, note) {
  writeCellByHeader_(sheet, row, responseMap, '同步狀態', status);
  writeCellByHeader_(sheet, row, responseMap, '同步時間', nowText_());
  writeCellByHeader_(sheet, row, responseMap, '同步備註', note);
}

function appendNoteToExistingActivity_(sheet, headerMap, row, note) {
  const currentNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();
  if (currentNote.indexOf(note) >= 0) return;

  const newNote = [
    currentNote,
    '',
    '【同步防呆紀錄】',
    note
  ].filter(Boolean).join('\n');

  writeCellByHeader_(sheet, row, headerMap, '備註', newNote);
  writeCellByHeader_(sheet, row, headerMap, '最近更新時間', nowText_());
}


/**
 * ============================================================
 * ActivityService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:活動資料包建立與回填。
 */
function createActivityPackageFromSelectedRow() {
  const context = getSelectedActivityRowContext_();
  const result = ensureActivityPackageForContext_(context);
  updateDashboard_();
  alert_([
    '活動資料包已建立或確認存在。',
    '活動:' + result.activity.name,
    '資料夾:' + result.folder.getUrl(),
    '',
    '下一步可建立報名表單或行政文件模板。'
  ].join('\n'));
  return result.folder.getUrl();
}

function ensureActivityPackageForContext_(context) {
  initializeSheets_(context.ss);
  let activity = context.activity;
  if (!activity.id) {
    activity.id = generateActivityId_();
    writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動ID', activity.id);
  }

  let folder = getFolderFromActivityUrl_(activity.folderUrl);
  if (!folder) {
    folder = createActivityFolder_(activity);
    writeCellByHeader_(context.sheet, context.row, context.headerMap, '專案資料夾連結', folder.getUrl());
    activity.folderUrl = folder.getUrl();
  }

  const subfolders = createActivitySubfolders_(folder);
  generateSessionsForActivity_(activity);
  updateActivitySessionFields_(context);
  if (!activity.summaryDocUrl) {
    const summaryDoc = createActivitySummaryDoc_(activity, subfolders['00_活動基本資料']);
    writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動摘要連結', summaryDoc.getUrl());
    appendDocumentListRow_(activity, '活動摘要', summaryDoc.getName(), '集中呈現活動基本資料與承辦人確認事項', summaryDoc.getUrl(), '系統模板', '草稿', '待確認', '建立活動資料包時產生');
  }

  appendDefaultTaskRows_(activity);
  appendDefaultNotificationRows_(activity);
  appendDefaultOutcomeRows_(activity);
  appendNotebookIndexRows_(activity);
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '目前狀態', '資料包已建立');
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
  return { activity: activity, folder: folder, subfolders: subfolders };
}

function ensureActivityPackageForSelectedActivity_() {
  const context = getSelectedActivityRowContext_();
  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  packageContext.activity = context.activity;
  packageContext.rowContext = context;
  return packageContext;
}

function createActivitySummaryDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_活動摘要_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('活動摘要').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('一、活動基本資料').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph(activityToText_(activity));
  body.appendParagraph('二、承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  [
    '活動日期、地點與時間是否正確。',
    '費用、收費方式、保險與交通是否符合校內程序。',
    '家長通知書與同意書正式發出前是否已核定。',
    '報名資料與 NotebookLM 匯出資料是否已排除不必要個資。'
  ].forEach(function(item) {
    body.appendParagraph('□ ' + item);
  });
  body.appendParagraph('三、AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph(CAMP_PROMPTS.activityDiagnosis.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
  body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function appendDefaultTaskRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
  ensureHeaders_(sheet, CAMP_TASK_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) sessions = generateSessionsForActivity_(activity);
  const startDate = effectiveActivityStartDate_(activity);
  const endDate = effectiveActivityEndDate_(activity) || startDate;
  const rows = [];
  [
    ['ALL', 'A01', '啟動', '建立活動資料包', '建立活動主資料夾、場次設定與中控台索引', '活動資料包', '資料夾與活動摘要已建立', '承辦人', activity.owner, '', addDays_(startDate, -30), '', '', '是', '活動摘要', '未開始', '高', '', ''],
    ['ALL', 'A02', '報名', '建立報名表單', '確認全程與部分場次報名欄位', '報名表單', '表單連結已回填活動清單', '承辦人', activity.owner, '', addDays_(startDate, -21), '', 'A01', '是', '報名表單', '未開始', '高', '', ''],
    ['ALL', 'A03', '通知', '發布活動通知', '家長或學員通知與處室通知完成確認', '通知文件', '正式通知已由承辦人確認', '承辦人', activity.owner, '', addDays_(startDate, -14), '', 'A02', '是', '家長通知書/處室通知', '未開始', '高', '', ''],
    ['ALL', 'A04', '名冊', '彙整總報名名單', '整理全程與部分場次報名統計', '報名統計', '場次報名人數已更新', '承辦人', activity.owner, '', activity.registrationDeadline, '', 'A02', '是', '報名資料', '未開始', '高', '', ''],
    ['ALL', 'A05', '行政確認', '確認收費/保險/交通', '確認活動行政風險與校內程序', '行政確認紀錄', '正式內容已確認', '承辦人', activity.owner, '', addDays_(startDate, -10), '', 'A03', '是', '標案需求表', '未開始', '高', '', ''],
    ['POST', 'P01', '結案', '建立 NotebookLM 資料包', '去識別化後整理多日活動資料包', 'NotebookLM資料包', '匯出文件已建立', '承辦人', activity.owner, '', addDays_(endDate, 7), '', '', '否', 'NotebookLM資料包', '未開始', '中', '', ''],
    ['POST', 'P02', '結案', '結案成果報告', '彙整成果、回饋、照片說明與下次建議', '成果報告素材', '成果檢查表已完成', '承辦人', activity.owner, '', addDays_(endDate, 14), '', 'P01', '否', '成果檢查表', '未開始', '中', '', '']
  ].forEach(function(item) {
    rows.push(buildTaskSheetRow_({
      '活動ID': activity.id,
      '場次ID': item[0],
      'WBS編碼': item[1],
      '階段': item[2],
      '任務名稱': item[3],
      '具體任務': item[4],
      '交付物': item[5],
      '完成判準': item[6],
      '負責角色': item[7],
      '負責人': item[8],
      '建議開始日期': item[9],
      '建議完成日期': item[10],
      '實際完成日期': item[11],
      '前置任務ID': item[12],
      '是否關鍵任務': item[13],
      '需要文件或表單': item[14],
      '狀態': item[15],
      '優先程度': item[16],
      '卡關原因': item[17],
      '備註': item[18],
      '最後更新時間': ''
    }));
  });

  sessions.forEach(function(session) {
    [
      ['課前', '確認本場講義', '確認本場講義、材料或簡報', '本場講義', '講義連結已放入場次設定', addDays_(parseDate_(session.date), -3), '高'],
      ['課前', '確認本場場地或線上連結', '確認場地、設備、線上會議連結與助教分工', '場地或線上連結', '場次地點與設備已確認', addDays_(parseDate_(session.date), -2), '高'],
      ['課前', '課前提醒學員', '提醒日期、時間、地點、攜帶物品與簽到方式', '課前提醒', '提醒已發出或公告', addDays_(parseDate_(session.date), -1), '中'],
      ['課前', '確認簽到表單', '確認逐場簽到資料與簽到流程', '簽到資料', '簽到預備列已建立', addDays_(parseDate_(session.date), -1), '高'],
      ['課後', '課後回收回饋', '回收本場回饋與問題紀錄', '回饋摘要', '回饋數已更新', parseDate_(session.date), '中'],
      ['課後', '整理本場成果', '整理照片、講義、錄影、回饋與成果說明', '本場成果資料', '成果整理狀態已更新', addDays_(parseDate_(session.date), 2), '中']
    ].forEach(function(item, index) {
      rows.push(buildTaskSheetRow_({
        '活動ID': activity.id,
        '場次ID': session.sessionId,
        'WBS編碼': session.sessionId + '-T' + ('0' + (index + 1)).slice(-2),
        '階段': item[0],
        '任務名稱': item[1],
        '具體任務': item[2],
        '交付物': item[3],
        '完成判準': item[4],
        '負責角色': '承辦人/協作人員',
        '負責人': activity.owner,
        '建議開始日期': '',
        '建議完成日期': item[5],
        '實際完成日期': '',
        '前置任務ID': '',
        '是否關鍵任務': item[6] === '高' ? '是' : '否',
        '需要文件或表單': item[3],
        '狀態': '未開始',
        '優先程度': item[6],
        '卡關原因': '',
        '備註': '',
        '最後更新時間': ''
      }));
    });
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_TASK_HEADERS.length).setValues(rows);
}

function buildTaskSheetRow_(taskData) {
  return CAMP_TASK_HEADERS.map(function(header) {
    return Object.prototype.hasOwnProperty.call(taskData, header) ? taskData[header] : '';
  });
}

function appendDefaultNotificationRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  const activityDate = parseDate_(activity.activityDate);
  const rows = [
    ['家長活動通知', '家長', '紙本/Email/班級群組', addDays_(activityDate, -14), '', activity.parentNoticeUrl || '', '草稿', '待確認', '正式發出前需確認日期、費用、交通與保險'],
    ['家長同意書回收提醒', '家長', '紙本/班級群組', activity.consentDeadline || activity.registrationDeadline, '', activity.parentConsentUrl || '', '未通知', '待確認', '追蹤未回覆名單'],
    ['導師行前提醒', '導師', '校內群組/Email', addDays_(activityDate, -7), '', activity.teacherReminderUrl || '', '草稿', '待確認', '確認點名與回報方式'],
    ['相關處室協調', '總務/學務/健康中心/會計', 'Email/會議', addDays_(activityDate, -14), '', activity.officeEmailUrl || '', '草稿', '待確認', '採購、保險、交通、安全與核銷'],
    ['成果資料收件提醒', '導師/協辦人', '校內群組/Email', addDays_(activityDate, 3), '', '', '未通知', '待確認', '活動後整理成果與照片說明']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTIFICATION_HEADERS.length).setValues(rows);
}

function appendDefaultOutcomeRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.OUTCOMES);
  ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) sessions = generateSessionsForActivity_(activity);
  const rows = [
    ['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '需補照片說明'],
    ['簽到或報名統計', '待確認', '', '是', '否', activity.owner, '含學生資料,不建議直接匯入 NotebookLM'],
    ['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可作為成果報告素材'],
    ['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
    ['經費或核銷附件', '不適用', '', '待確認', '否', activity.owner, '依校內規定處理'],
    ['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM 資料包']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  sessions.forEach(function(session) {
    [
      [session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
      [session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
      [session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
      [session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
    ].forEach(function(row) {
      rows.push([activity.id].concat(row));
    });
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
}

function appendNotebookIndexRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTEBOOK);
  ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  const rows = [
    ['活動基本資料', activityToText_(activity), '01_活動清單', '是', '不含敏感個資時可匯出'],
    ['報名統計', '報名人數、參加人數、不參加人數、特殊需求摘要', '02_報名資料', '是', '只放統計與摘要,不放電話與健康細節'],
    ['任務狀態', '任務追蹤與完成狀態', '03_任務追蹤', '是', ''],
    ['通知紀錄', '家長、導師、處室通知狀態', '05_通知管理', '是', '正式通知內容需承辦人確認'],
    ['成果檢查', '成果資料、照片說明、下次建議', '06_成果檢查', '是', ''],
    ['敏感資料', '學生姓名、電話、健康、身分資料、收費明細', '各表單原始回覆', '否', '不放入 NotebookLM 資料包']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTEBOOK_HEADERS.length).setValues(rows);
}


/**
 * ============================================================
 * RegistrationTemplateService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:報名表模板與條件欄位判斷。
 */
function isYes_(value) {
  return String(value || '').trim() === '是';
}

function getRegistrationTemplateType_(activity) {
  const configured = String(activity.registrationTemplateType || '').trim();
  if (configured && configured !== '自動判斷') return configured;
  if (isWorkshopActivity_(activity) && isOnlineActivity_(activity)) return '線上研習';
  if (isWorkshopActivity_(activity)) return '教師研習';
  if (isStudentActivity_(activity)) return '學生營隊';
  return '自動判斷';
}

function isWorkshopActivity_(activity) {
  const template = String(activity.registrationTemplateType || '');
  const type = String(activity.type || '');
  const name = String(activity.name || '');
  if (template.indexOf('教師研習') >= 0 || template.indexOf('線上研習') >= 0) return true;
  return type.indexOf('研習') >= 0
    || type.indexOf('工作坊') >= 0
    || name.indexOf('研習') >= 0
    || name.indexOf('工作坊') >= 0;
}

function isStudentActivity_(activity) {
  const template = String(activity.registrationTemplateType || '');
  const type = String(activity.type || '');
  if (template.indexOf('學生營隊') >= 0 || template.indexOf('校外教學') >= 0 || template.indexOf('親子活動') >= 0 || template.indexOf('競賽活動') >= 0) return true;
  return type.indexOf('營隊') >= 0
    || type.indexOf('校外教學') >= 0
    || type.indexOf('校內活動') >= 0
    || type.indexOf('競賽') >= 0;
}

function isOnlineActivity_(activity) {
  const template = String(activity.registrationTemplateType || '');
  const location = String(activity.location || '');
  const transport = String(activity.transport || '');
  if (template.indexOf('線上研習') >= 0) return true;
  return location.indexOf('Google Meet') >= 0
    || location.indexOf('線上') >= 0
    || transport.indexOf('線上') >= 0;
}

function needsDietSurvey_(activity) {
  if (isOnlineActivity_(activity)) return false;
  const type = String(activity.type || '');
  const feeNote = String(activity.feeNote || '');
  const note = String(activity.note || '');
  return type.indexOf('營隊') >= 0
    || type.indexOf('校外教學') >= 0
    || feeNote.indexOf('餐') >= 0
    || note.indexOf('餐') >= 0
    || note.indexOf('便當') >= 0;
}

function needsInsuranceFields_(activity) {
  return isYes_(activity.insuranceRequired);
}

function needsConsentFields_(activity) {
  return isYes_(activity.consentRequired);
}

function needsPaymentFields_(activity) {
  return isYes_(activity.chargeRequired);
}

function needsPartialSessionFields_(activity) {
  return isYes_(activity.allowPartialSessions);
}

function buildWorkshopSessionChoiceValues_(activity, sessions) {
  if (String(activity.id || '') === 'ACT-NTPC-AI-11506') {
    return [
      'S01|6月15日(一) 課程主題|從公文啟動專案 主案例:教育局來文「AI 融入教學教師研習計畫」',
      'S02|6月18日(四) 課程主題|活動型專案管理 主案例:校外教學/親子活動辦理',
      'S03|6月22日(一) 課程主題|成果型專案與 AI 文件整理 主案例:教育優先區/補助計畫成果報告',
      'S04|6月25日(四) 課程主題|大型專案整合與制度化 主案例:運動會/校慶大型行政專案'
    ];
  }
  return buildSessionChoiceValues_(sessions);
}


/**
 * ============================================================
 * RegistrationForm.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:報名表單與回應同步。
 */
function createRegistrationFormForSelectedActivity() {
  const packageContext = getRegistrationPackageContextFromSelectedActivity_();
  if (!packageContext) return '';
  return createRegistrationFormForPackageContext_(packageContext);
}

function getRegistrationPackageContextFromSelectedActivity_() {
  const context = getSelectedActivityRowContext_();
  if (warnIfPossibleDuplicateNewTaipeiWorkshop_(context.activity)) {
    return null;
  }
  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  packageContext.activity = context.activity;
  packageContext.rowContext = context;
  return packageContext;
}

function warnIfPossibleDuplicateNewTaipeiWorkshop_(activity) {
  const name = String(activity.name || '');
  const id = String(activity.id || '');

  if (name.indexOf('新北AI共創大聯盟') >= 0 && id !== 'ACT-NTPC-AI-11506') {
    alert_([
      '你目前選取的活動名稱像是新北AI研習,但活動ID不是預設示範資料 ID。',
      '',
      '目前活動ID:' + valueOrPending_(id),
      '建議示範資料ID:ACT-NTPC-AI-11506',
      '',
      '如果這是重複同步產生的資料,請改選 ACT-NTPC-AI-11506 那一列。'
    ].join('\n'));
    return true;
  }

  return false;
}

function createRegistrationFormForPackageContext_(packageContext) {
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;

  if (activity.registrationFormUrl && activity.registrationFormEditUrl) {
    alert_('此活動已經有報名表單:\n' + activity.registrationFormUrl);
    return activity.registrationFormUrl;
  }
  generateSessionsForActivity_(activity);
  const sessions = getSessionsByActivityId_(activity.id);

  const form = FormApp.create('報名表單_' + activity.name);
  form.setDescription([
    '活動名稱:' + activity.name,
    '活動日期:' + formatDateValue_(activity.activityDate),
    '活動地點:' + valueOrPending_(activity.location),
    '報名截止日:' + formatDateValue_(activity.registrationDeadline),
    '',
    '個資使用提醒:本表單資料僅供本次活動報名、通知、保險、聯繫與行政確認使用。正式保存、分享與刪除仍依校內規範辦理。'
  ].join('\n'));
  form.setAllowResponseEdits(true);
  form.setDestination(FormApp.DestinationType.SPREADSHEET, rowContext.ss.getId());

  const activityIdItem = form.addTextItem().setTitle('活動ID').setRequired(true).setHelpText('系統欄位,請勿修改。');
  const activityNameItem = form.addTextItem().setTitle('活動名稱').setRequired(true).setHelpText('系統欄位,請勿修改。');

  if (isWorkshopActivity_(activity)) {
    form.addTextItem().setTitle('單位').setRequired(true);
    form.addTextItem().setTitle('科別/職稱').setRequired(true);
    form.addTextItem().setTitle('姓名').setRequired(true);
    form.addTextItem().setTitle('Email').setRequired(false);
    form.addCheckboxItem()
      .setTitle('參加場次')
      .setChoiceValues(buildWorkshopSessionChoiceValues_(activity, sessions))
      .setRequired(true);
    form.addMultipleChoiceItem()
      .setTitle('需要研習時數')
      .setChoiceValues(['需要', '不需要', '待確認'])
      .setRequired(false);
    form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);
    form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
  } else {
    form.addTextItem().setTitle('學生姓名').setRequired(true);
    form.addTextItem().setTitle('班級').setRequired(true);
    form.addTextItem().setTitle('座號').setRequired(false);
    form.addTextItem().setTitle('家長姓名').setRequired(true);
    form.addTextItem().setTitle('家長Email').setRequired(false);
    form.addTextItem().setTitle('家長電話').setRequired(false);
    form.addMultipleChoiceItem().setTitle('是否參加').setChoiceValues(['參加', '不參加', '需再確認']).setRequired(true);

    if (needsPartialSessionFields_(activity)) {
      form.addMultipleChoiceItem().setTitle('是否全程參加').setChoiceValues(['是,我會全程參加', '否,我只參加部分場次']).setRequired(true);
      form.addCheckboxItem().setTitle('選擇參加場次').setChoiceValues(buildSessionChoiceValues_(sessions)).setRequired(false);
    }

    form.addMultipleChoiceItem().setTitle('需要研習時數').setChoiceValues(['需要', '不需要', '待確認']).setRequired(false);
    form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
    form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);

    if (needsDietSurvey_(activity)) {
      form.addMultipleChoiceItem().setTitle('飲食需求').setChoiceValues(['無特殊需求', '葷食', '素食', '其他']).setRequired(false);
    }

    if (needsInsuranceFields_(activity)) {
      form.addTextItem().setTitle('出生日期').setRequired(false);
      form.addTextItem().setTitle('身分證字號').setRequired(false);
      form.addMultipleChoiceItem().setTitle('是否同意提供保險所需資料').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
    }

    if (needsConsentFields_(activity)) {
      form.addMultipleChoiceItem().setTitle('家長是否同意參加').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
      form.addMultipleChoiceItem().setTitle('同意書狀態').setChoiceValues(['已繳交', '尚未繳交', '不適用']).setRequired(false);
    }

    if (needsPaymentFields_(activity)) {
      form.addMultipleChoiceItem().setTitle('繳費方式').setChoiceValues(['現金', '轉帳', '學校代收', '其他', '待確認']).setRequired(false);
      form.addParagraphTextItem().setTitle('繳費備註').setRequired(false);
    }

    form.addCheckboxItem().setTitle('特殊需求').setChoiceValues(['無', '暈車', '醫療需求', '過敏', '交通接送', '其他']).setRequired(false);
  }

  form.addMultipleChoiceItem().setTitle('是否同意通知').setChoiceValues(['同意', '不同意']).setRequired(true);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);

  const prefilledUrl = form.createResponse()
    .withItemResponse(activityIdItem.createResponse(activity.id))
    .withItemResponse(activityNameItem.createResponse(activity.name))
    .toPrefilledUrl();

  moveFileToFolder_(form.getId(), packageContext.subfolders['01_報名表單與回覆']);
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單連結', prefilledUrl);
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單編輯連結', form.getEditUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名回應表連結', rowContext.ss.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());

  appendDocumentListRow_(activity, '報名表單', form.getTitle(), '收集活動報名、場次選擇與行政需求資料', prefilledUrl, 'Google Form', '待確認', '待確認', '發出前需確認欄位與個資告知文字;模板:' + getRegistrationTemplateType_(activity));
  updateDashboard_();
  alert_('報名表單已建立:\n' + prefilledUrl);
  return prefilledUrl;
}

function syncRegistrationResponses() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const targetSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  ensureHeaders_(targetSheet, CAMP_REGISTRATION_HEADERS);
  const existingKeys = buildExistingRegistrationKeys_(targetSheet);
  const systemSheetNames = {};
  CAMP_SHEET_ORDER.forEach(function(name) {
    systemSheetNames[name] = true;
  });

  let added = 0;
  ss.getSheets().forEach(function(sheet) {
    if (systemSheetNames[sheet.getName()]) return;
    if (!looksLikeRegistrationResponseSheet_(sheet)) return;
    const values = sheet.getDataRange().getValues();
    const headers = values[0];
    const index = buildArrayHeaderIndex_(headers);
    for (let i = 1; i < values.length; i++) {
      const row = values[i];
      const activityId = readArrayCell_(row, index, '活動ID');
      const studentName = readArrayCell_(row, index, '學生姓名')
        || readArrayCell_(row, index, '姓名');
      const unit = readArrayCell_(row, index, '班級')
        || readArrayCell_(row, index, '單位');
      const title = readArrayCell_(row, index, '座號')
        || readArrayCell_(row, index, '科別/職稱')
        || readArrayCell_(row, index, '科別/職稱')
        || readArrayCell_(row, index, '科別/ 職稱');
      const activityName = readArrayCell_(row, index, '活動名稱');
      const timestamp = readArrayCell_(row, index, '時間戳記') || readArrayCell_(row, index, 'Timestamp');
      if (!activityId || !studentName) continue;
      const key = [activityId, studentName, timestamp].join('|');
      if (existingKeys[key]) continue;
      const selectedText = readArrayCell_(row, index, '選擇參加場次') || readArrayCell_(row, index, '參加場次');
      const fullParticipation = readArrayCell_(row, index, '是否全程參加')
        || inferFullParticipationFromSelectedSessions_(selectedText);
      const selectedSessions = normalizeSelectedSessions_(activityId, fullParticipation, selectedText);
      const email = readArrayCell_(row, index, '家長Email') || readArrayCell_(row, index, 'Email');
      targetSheet.appendRow([
        activityId,
        activityName,
        timestamp,
        studentName,
        unit,
        title,
        readArrayCell_(row, index, '家長姓名'),
        email,
        readArrayCell_(row, index, '家長電話'),
        readArrayCell_(row, index, '是否參加') || '參加',
        fullParticipation,
        selectedSessions,
        readArrayCell_(row, index, '需要研習時數'),
        readArrayCell_(row, index, '學習期待'),
        readArrayCell_(row, index, '目前行政痛點'),
        readArrayCell_(row, index, '是否同意通知'),
        readArrayCell_(row, index, '飲食需求'),
        readArrayCell_(row, index, '特殊需求'),
        readArrayCell_(row, index, '同意書狀態'),
        '否',
        sheet.getName(),
        buildRegistrationNote_(row, index, title)
      ]);
      ensureAttendanceRowsForRegistration_({
        activityId: activityId,
        name: studentName,
        unit: unit,
        email: email,
        fullParticipation: fullParticipation,
        selectedSessions: selectedSessions
      });
      existingKeys[key] = true;
      added++;
    }
  });

  updateRegistrationStats_();
  updateSessionStats_();
  updateDashboard_();
  alert_('報名資料同步完成。新增 ' + added + ' 筆。');
  return added;
}

function buildSessionChoiceValues_(sessions) {
  if (!sessions.length) return ['S01|第1場'];
  return sessions.map(function(session) {
    return [
      session.sessionId,
      formatDateValue_(session.date),
      session.title || ('第' + session.sessionId.replace('S', '') + '場'),
      session.topic || ''
    ].filter(String).join('|');
  });
}

function normalizeSelectedSessions_(activityId, fullParticipation, selectedText) {
  const sessions = getSessionsByActivityId_(activityId);
  if (String(fullParticipation || '').indexOf('全程') >= 0 || String(fullParticipation || '') === '是') {
    return sessions.map(function(session) { return session.sessionId; }).join(',');
  }
  const selected = parseSelectedSessionIds_(selectedText);
  if (selected.length) return selected.join(',');
  return sessions.length === 1 ? sessions[0].sessionId : '';
}

function inferFullParticipationFromSelectedSessions_(selectedText) {
  const count = parseSelectedSessionIds_(selectedText).length;
  return count >= 4 ? '是,我會全程參加' : '否,我只參加部分場次';
}

function buildRegistrationNote_(row, index, title) {
  const note = String(readArrayCell_(row, index, '備註') || '').trim();
  const workshopTitle = String(title || '').trim();
  if (!workshopTitle) return note;
  return [note, '科別/職稱:' + workshopTitle].filter(String).join('\n');
}

function updateRegistrationStats_() {
  const ss = getActiveSpreadsheet_();
  const registrationSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const registrationRows = getRegistrationStatsByActivity_(registrationSheet);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);

  for (let row = 2; row <= activitySheet.getLastRow(); row++) {
    const activityId = readCellByHeader_(activitySheet, row, activityMap, '活動ID');
    if (!activityId) continue;
    const stats = registrationRows[String(activityId)] || { total: 0, join: 0, notJoin: 0, special: 0 };
    writeCellByHeader_(activitySheet, row, activityMap, '報名人數', stats.total);
    writeCellByHeader_(activitySheet, row, activityMap, '同意參加', stats.join);
    writeCellByHeader_(activitySheet, row, activityMap, '不參加', stats.notJoin);
    writeCellByHeader_(activitySheet, row, activityMap, '特殊需求數', stats.special);
    writeCellByHeader_(activitySheet, row, activityMap, '最近更新時間', nowText_());
  }
}

function getRegistrationStatsByActivity_(sheet) {
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
  const map = buildHeaderMap_(sheet);
  const stats = {};
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
    if (!activityId) continue;
    if (!stats[activityId]) stats[activityId] = { total: 0, join: 0, notJoin: 0, special: 0 };
    stats[activityId].total++;
    const participate = String(readCellByHeader_(sheet, row, map, '是否參加') || '');
    if (participate === '參加') stats[activityId].join++;
    if (participate === '不參加') stats[activityId].notJoin++;
    const special = String(readCellByHeader_(sheet, row, map, '特殊需求') || '').trim();
    if (special && special !== '無') stats[activityId].special++;
  }
  return stats;
}

function buildExistingRegistrationKeys_(sheet) {
  const map = buildHeaderMap_(sheet);
  const keys = {};
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const key = [
      readCellByHeader_(sheet, row, map, '活動ID'),
      readCellByHeader_(sheet, row, map, '學生姓名'),
      readCellByHeader_(sheet, row, map, '時間戳記')
    ].join('|');
    keys[key] = true;
  }
  return keys;
}

function looksLikeRegistrationResponseSheet_(sheet) {
  if (sheet.getLastRow() < 1 || sheet.getLastColumn() < 3) return false;
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
  const hasName = headers.indexOf('學生姓名') >= 0 || headers.indexOf('姓名') >= 0;
  const hasParticipation = headers.indexOf('是否參加') >= 0
    || headers.indexOf('選擇參加場次') >= 0
    || headers.indexOf('參加場次') >= 0;
  return headers.indexOf('活動ID') >= 0 && hasName && hasParticipation;
}

function buildArrayHeaderIndex_(headers) {
  const index = {};
  headers.forEach(function(header, i) {
    if (header) index[String(header).trim()] = i;
  });
  return index;
}

function readArrayCell_(row, index, header) {
  return index[header] === undefined ? '' : row[index[header]];
}


/**
 * ============================================================
 * SeedRegistrationData.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:教學用報名假資料。
 */
function createRegistrationFormAndSeedSampleData() {
  const packageContext = getRegistrationPackageContextFromSelectedActivity_();
  if (!packageContext) return '';
  const formUrl = createRegistrationFormForPackageContext_(packageContext);
  const seeded = seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
  alert_([
    '報名表單已建立或確認存在。',
    '',
    '本次新增假報名資料:' + seeded + ' 筆。',
    '已更新報名統計、場次統計、簽到預備資料與中控台。',
    '',
    '報名表單:',
    formUrl
  ].join('\n'));
  return formUrl;
}

function seedSampleWorkshopRegistrationsForSelectedActivity() {
  const packageContext = getRegistrationPackageContextFromSelectedActivity_();
  if (!packageContext) return 0;
  return seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
}

function seedSampleWorkshopRegistrationsForPackageContext_(packageContext) {
  const activity = packageContext.activity;

  if (!activity.id) {
    throw new Error('此活動缺少活動ID,請先建立活動資料包或補上活動ID。');
  }

  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);

  const existingRows = getRowsByActivityId_(sheet, activity.id);
  const hasOfficialRows = existingRows.some(function(row) {
    return String(row['來源分頁'] || '') !== '系統假資料';
  });
  if (hasOfficialRows) {
    alert_('此活動已有正式報名資料,為避免混入教學假資料,本次不自動產生 50 筆假資料。');
    return 0;
  }
  if (existingRows.length >= 50) {
    alert_('此活動已有 ' + existingRows.length + ' 筆報名資料,不重複 seed 50 筆。');
    return 0;
  }

  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) {
    generateSessionsForActivity_(activity);
    sessions = getSessionsByActivityId_(activity.id);
  }

  const remaining = Math.max(0, 50 - existingRows.length);
  const sampleRows = buildSampleWorkshopRegistrationRows_(activity, remaining, existingRows.length);
  if (!sampleRows.length) return 0;

  sheet.getRange(sheet.getLastRow() + 1, 1, sampleRows.length, CAMP_REGISTRATION_HEADERS.length)
    .setValues(sampleRows);

  sampleRows.forEach(function(row) {
    ensureAttendanceRowsForRegistration_({
      activityId: row[CAMP_REGISTRATION_HEADERS.indexOf('活動ID')],
      name: row[CAMP_REGISTRATION_HEADERS.indexOf('學生姓名')],
      unit: row[CAMP_REGISTRATION_HEADERS.indexOf('班級')],
      email: row[CAMP_REGISTRATION_HEADERS.indexOf('家長Email')],
      fullParticipation: row[CAMP_REGISTRATION_HEADERS.indexOf('是否全程參加')],
      selectedSessions: row[CAMP_REGISTRATION_HEADERS.indexOf('選擇參加場次')]
    });
  });

  updateRegistrationStats_();
  updateSessionStats_();
  updateDashboard_();
  return sampleRows.length;
}

function buildSampleWorkshopRegistrationRows_(activity, count, offset) {
  const units = [
    '新北市政府教育局', '八里國小', '三和國中', '三芝國中', '三義高中',
    '竹南國小', '新莊國小', '板橋國小', '泰山高中', '淡水國中',
    '一般企業', '一般民眾', '人力學院', '教育科技中心', '資訊教育輔導團'
  ];
  const titles = [
    '國小教育科', '主任', '教師', '科任教師', '資訊組長',
    '教務主任', '學務主任', '行政人員', '採購', '上班族',
    '綜合規劃組', '輔導員', '研究教師', '自然科教師', '總務主任'
  ];
  const names = [
    '王品安', '林書宇', '陳郁庭', '張雅筑', '李承恩',
    '黃子晴', '劉冠廷', '吳怡萱', '蔡孟潔', '楊皓宇',
    '許庭瑄', '鄭宇翔', '謝佳穎', '洪柏翰', '郭采蓉',
    '曾昱廷', '邱郁涵', '廖品妤', '賴冠宇', '周宜蓁',
    '徐子軒', '蘇映潔', '葉哲維', '江佩珊', '何宥辰',
    '蕭雅雯', '羅品萱', '高承翰', '潘怡君', '游宗翰',
    '程心瑜', '方柏宇', '石婉婷', '沈昱翔', '戴佳蓉',
    '彭宇恩', '詹雅婷', '卓冠霖', '簡若涵', '藍柏翰',
    '梁芷晴', '康家豪', '姜宜庭', '白承祐', '任品妍',
    '歐陽子晴', '范庭瑋', '宋宜蓁', '古承恩', '杜欣妤'
  ];
  const painPoints = [
    '資料分散在不同雲端資料夾,常常找不到最新版。',
    '活動報名、通知、名冊整理需要重複複製貼上。',
    '公文轉成待辦任務時,容易漏掉期限。',
    '想把表單、試算表、文件和提醒整合起來。',
    '希望學會用 AI 協助整理成果報告草稿。',
    '行政流程常需要跨處室協作,希望能更清楚追蹤。',
    '想知道 NotebookLM 可以如何用在行政資料整理。',
    '希望了解 Apps Script 在校務行政的實際應用。'
  ];
  const expectations = [
    '希望能完成一套可複製的行政專案流程。',
    '想學會從公文建立專案追蹤表。',
    '希望能降低人工整理資料的時間。',
    '想把活動報名與成果整理變得更有系統。',
    '希望能做出適合學校同仁使用的範本。',
    '想理解 AI 在行政流程中的合理位置。',
    '希望回校後能實際套用在研習或活動辦理。',
    '想學會建立 NotebookLM 資料包。'
  ];

  const rows = [];
  for (let i = 0; i < count; i++) {
    const serial = offset + i;
    const name = names[serial % names.length];
    const unit = units[serial % units.length];
    const title = titles[serial % titles.length];
    const email = 'sample' + ('000' + (serial + 1)).slice(-3) + '@example.com';
    const selectedSessions = buildSeedSelectedSessions_(serial);
    const fullParticipation = selectedSessions === 'S01,S02,S03,S04' ? '是,我會全程參加' : '否,我只參加部分場次';
    const timestamp = new Date(2026, 5, 1 + (serial % 5), 9 + (serial % 10), (serial * 7) % 60, (serial * 11) % 60);
    const object = {};
    object['活動ID'] = activity.id;
    object['活動名稱'] = activity.name;
    object['時間戳記'] = timestamp;
    object['學生姓名'] = name;
    object['班級'] = unit;
    object['座號'] = title;
    object['家長姓名'] = '';
    object['家長Email'] = email;
    object['家長電話'] = '';
    object['是否參加'] = '參加';
    object['是否全程參加'] = fullParticipation;
    object['選擇參加場次'] = selectedSessions;
    object['需要研習時數'] = serial % 4 === 0 ? '不需要' : '需要';
    object['學習期待'] = expectations[serial % expectations.length];
    object['目前行政痛點'] = painPoints[serial % painPoints.length];
    object['是否同意通知'] = '同意';
    object['飲食需求'] = '';
    object['特殊需求'] = '';
    object['同意書狀態'] = '不適用';
    object['是否可提供 NotebookLM'] = '是';
    object['來源分頁'] = '系統假資料';
    object['備註'] = '教師研習示範資料,非真實個資。單位:' + unit + ';職稱:' + title;
    rows.push(CAMP_REGISTRATION_HEADERS.map(function(header) {
      return object[header] || '';
    }));
  }
  return rows;
}

function buildSeedSelectedSessions_(index) {
  const patterns = [
    'S01,S02,S03,S04',
    'S01,S02,S03,S04',
    'S01,S02,S03,S04',
    'S01,S02,S03,S04',
    'S01',
    'S02',
    'S03',
    'S04',
    'S01,S02',
    'S02,S03',
    'S03,S04',
    'S01,S04',
    'S01,S03',
    'S02,S04',
    'S01,S02,S03',
    'S02,S03,S04'
  ];
  return patterns[index % patterns.length];
}

function clearSeedRegistrationDataForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  const activity = context.activity;
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
  const map = buildHeaderMap_(sheet);

  for (let row = sheet.getLastRow(); row >= 2; row--) {
    const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
    const source = String(readCellByHeader_(sheet, row, map, '來源分頁') || '');
    if (activityId === activity.id && source === '系統假資料') {
      sheet.deleteRow(row);
    }
  }

  updateRegistrationStats_();
  updateSessionStats_();
  updateDashboard_();
  alert_('已清除本活動的系統假報名資料。');
}


/**
 * ============================================================
 * DocumentTemplates.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:行政文件模板。
 */
function createDocumentTemplatesForSelectedActivity() {
  const packageContext = ensureActivityPackageForSelectedActivity_();
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;
  const subfolders = packageContext.subfolders;

  const parentNotice = createParentNoticeDoc_(activity, subfolders['02_家長通知與同意書']);
  const parentConsent = createParentConsentDoc_(activity, subfolders['02_家長通知與同意書']);
  const procurement = createProcurementRequirementDoc_(activity, subfolders['03_處室聯繫與採購']);
  const officeEmail = createOfficeEmailDraftDoc_(activity, subfolders['03_處室聯繫與採購']);
  const teacherReminder = createTeacherReminderDoc_(activity, subfolders['03_處室聯繫與採購']);
  const outcomeSheet = createOutcomeChecklistSheet_(activity, subfolders['06_成果檢查與結案']);

  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長通知書連結', parentNotice.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長同意書連結', parentConsent.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '標案需求表連結', procurement.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '處室通知草稿連結', officeEmail.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '導師行前提醒連結', teacherReminder.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '目前狀態', '文件模板已建立');
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());

  activity.parentNoticeUrl = parentNotice.getUrl();
  activity.parentConsentUrl = parentConsent.getUrl();
  activity.procurementUrl = procurement.getUrl();
  activity.officeEmailUrl = officeEmail.getUrl();
  activity.teacherReminderUrl = teacherReminder.getUrl();
  activity.outcomeUrl = outcomeSheet.getUrl();

  appendDocumentListRow_(activity, '家長通知書', parentNotice.getName(), '提供家長活動資訊與回覆提醒', parentNotice.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '家長同意書', parentConsent.getName(), '提供家長簽署或確認參加意願', parentConsent.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '標案需求表', procurement.getName(), '整理採購、交通、保險或物資需求', procurement.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '處室通知草稿', officeEmail.getName(), '提供總務、學務、健康中心、會計或協辦處室通知草稿', officeEmail.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '導師行前提醒', teacherReminder.getName(), '提供導師活動前與活動當日提醒事項', teacherReminder.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '檢查活動後成果、照片、附件與 NotebookLM 可用資料', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');

  refreshNotificationDocumentLinks_(activity);
  updateDashboard_();
  alert_('行政文件模板已建立完成。請逐份確認後再正式公告、寄送或送簽。');
}

function createParentNoticeDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_家長通知書_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('家長通知書').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('親愛的家長您好:');
  body.appendParagraph('本校預計辦理「' + activity.name + '」,以下為活動資訊草稿,正式內容仍以承辦人確認後公告為準。');
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['活動開始日', formatDateValue_(activity.startDate)],
    ['活動結束日', formatDateValue_(activity.endDate)],
    ['活動地點', valueOrPending_(activity.location)],
    ['參加對象', valueOrPending_(activity.participants)],
    ['報名截止日', formatDateValue_(activity.registrationDeadline)],
    ['費用說明', valueOrPending_(activity.feeNote)],
    ['交通方式', valueOrPending_(activity.transport)],
    ['保險需求', valueOrPending_(activity.insuranceRequired)]
  ]);
  body.appendParagraph('場次表').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendSessionsTable_(body, activity.id);
  body.appendParagraph('家長需協助事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph('請於期限內完成報名或同意書回覆,並留意導師或學校後續通知。');
  body.appendParagraph('AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph(CAMP_PROMPTS.parentNotice.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['日期、時間、地點正確', '費用與繳交方式正確', '交通、保險與安全資訊已確認', '正式發出前已依校內程序核定']);
  body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createParentConsentDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_家長同意書_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('家長同意書').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('本人已詳閱「' + activity.name + '」活動說明,並同意學生依學校規劃參與本次活動。');
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['活動地點', valueOrPending_(activity.location)],
    ['同意書回收日', formatDateValue_(activity.consentDeadline)],
    ['是否收費', valueOrPending_(activity.chargeRequired)],
    ['是否需要保險', valueOrPending_(activity.insuranceRequired)]
  ]);
  body.appendParagraph('學生姓名:____________________    班級座號:____________________');
  body.appendParagraph('家長簽名:____________________    聯絡電話:____________________');
  body.appendParagraph('特殊需求或注意事項:');
  body.appendParagraph('\n\n');
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['同意書欄位符合校內格式', '未收集不必要個資', '回收期限與保管方式已確認']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createProcurementRequirementDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_標案採購需求表_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('標案/採購需求表').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('本文件用於整理活動所需之交通、場地、保險、餐點、材料、設備或其他採購需求草稿。正式採購仍依校內與法規程序辦理。');
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['預估人數', valueOrPending_(activity.estimatedCount)],
    ['活動地點', valueOrPending_(activity.location)],
    ['交通方式', valueOrPending_(activity.transport)],
    ['費用說明', valueOrPending_(activity.feeNote)],
    ['承辦處室', valueOrPending_(activity.department)],
    ['承辦人', valueOrPending_(activity.owner)]
  ]);
  body.appendParagraph('需求項目草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendDocTable_(body, [
    ['需求類別', '需求內容', '數量', '規格', '預估日期', '備註'],
    ['交通', activity.transport || '待確認', activity.estimatedCount || '', '', formatDateValue_(activity.activityDate), ''],
    ['保險', activity.insuranceRequired || '待確認', activity.estimatedCount || '', '', '', ''],
    ['餐點或材料', '待確認', '', '', '', '']
  ]);
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['是否需要總務或會計確認', '採購方式是否符合規定', '經費來源與核銷附件是否明確']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createOfficeEmailDraftDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_處室通知Email草稿_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('處室通知 Email 草稿').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('主旨:【活動協作通知】' + activity.name);
  body.appendParagraph('收件對象:總務處、學務處、健康中心、會計室、導師或協辦同仁(請承辦人依實際情況調整)');
  body.appendParagraph('Email 內容草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph([
    '各位同仁您好:',
    '',
    '本校預計辦理「' + activity.name + '」,活動日期為 ' + formatDateValue_(activity.activityDate) + ',地點為 ' + valueOrPending_(activity.location) + '。',
    '敬請協助確認相關行政事項:',
    '1. 交通、場地或物資需求。',
    '2. 保險、健康或特殊需求學生提醒。',
    '3. 經費、採購、核銷或成果附件需求。',
    '4. 活動當日安全、集合、點名與回報方式。',
    '',
    '相關資料夾或文件連結請見中控台。',
    '',
    '承辦人:' + valueOrPending_(activity.owner),
    '承辦處室:' + valueOrPending_(activity.department)
  ].join('\n'));
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['收件對象是否正確', '不包含不應外寄的學生個資', '採購、保險、交通與安全內容已確認']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createTeacherReminderDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_導師行前提醒_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('導師/工作人員行前提醒').setHeading(DocumentApp.ParagraphHeading.TITLE);
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['活動地點', valueOrPending_(activity.location)],
    ['參加對象', valueOrPending_(activity.participants)]
  ]);
  body.appendParagraph('活動前一週').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['確認參加名冊', '提醒學生攜帶物品', '確認特殊需求或醫療提醒']);
  const sessions = getSessionsByActivityId_(activity.id);
  if (sessions.length > 1) {
    body.appendParagraph('逐場提醒').setHeading(DocumentApp.ParagraphHeading.HEADING1);
    sessions.forEach(function(session) {
      body.appendParagraph(session.sessionId + '|' + formatDateValue_(session.date) + '|' + valueOrPending_(session.title)).setHeading(DocumentApp.ParagraphHeading.HEADING2);
      appendChecklistParagraphs_(body, [
        '確認本場地點或線上連結:' + valueOrPending_(session.location),
        '確認本場簽到方式與名冊',
        '確認本場講義、設備與回報方式'
      ]);
    });
  }
  body.appendParagraph('活動前一天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['再次提醒集合時間與地點', '確認未回覆或特殊需求學生', '確認緊急聯絡方式']);
  body.appendParagraph('活動當天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['集合點名', '活動中定時確認人數', '返校或結束後回報']);
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['提醒內容是否符合實際活動', '不公開不必要個資', '安全與回報方式已確認']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createOutcomeChecklistSheet_(activity, folder) {
  const ss = SpreadsheetApp.create(activity.id + '_成果檢查表_' + sanitizeFileName_(activity.name));
  const sheet = ss.getSheets()[0];
  sheet.setName('成果檢查');
  sheet.getRange(1, 1, 1, CAMP_OUTCOME_HEADERS.length).setValues([CAMP_OUTCOME_HEADERS]);
  const rows = [
    ['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '請補照片說明'],
    ['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可用於成果報告'],
    ['參與統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放學生個資'],
    ['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
    ['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) sessions = generateSessionsForActivity_(activity);
  sessions.forEach(function(session) {
    [
      [session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
      [session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
      [session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
      [session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
    ].forEach(function(row) {
      rows.push([activity.id].concat(row));
    });
  });
  sheet.getRange(2, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
  formatHeaderRow_(sheet, CAMP_OUTCOME_HEADERS.length);
  moveFileToFolder_(ss.getId(), folder);
  return ss;
}

function appendDocTable_(body, rows) {
  const table = body.appendTable(rows);
  table.setBorderWidth(1);
  return table;
}

function appendChecklistParagraphs_(body, items) {
  items.forEach(function(item) {
    body.appendParagraph('□ ' + item);
  });
}


/**
 * ============================================================
 * NotificationService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:通知草稿與測試信。
 */
function sendTestEmailToOwner() {
  const context = getSelectedActivityRowContext_();
  const activity = context.activity;
  if (!activity.ownerEmail) throw new Error('此活動缺少「承辦人Email」,無法寄送測試信。');

  const subject = '【測試信】' + activity.name + ' 活動通知草稿檢查';
  const body = [
    '這是營隊活動管理中樞寄出的測試信,尚未寄送給家長或其他處室。',
    '',
    '活動名稱:' + activity.name,
    '活動日期:' + formatDateValue_(activity.activityDate),
    '活動地點:' + valueOrPending_(activity.location),
    '',
    '重要文件連結:',
    '專案資料夾:' + valueOrPending_(activity.folderUrl),
    '家長通知書:' + valueOrPending_(activity.parentNoticeUrl),
    '家長同意書:' + valueOrPending_(activity.parentConsentUrl),
    '處室通知草稿:' + valueOrPending_(activity.officeEmailUrl),
    '導師行前提醒:' + valueOrPending_(activity.teacherReminderUrl),
    '',
    CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
  ].join('\n');

  MailApp.sendEmail(activity.ownerEmail, subject, body);
  recordNotificationStatus_(activity, '承辦人測試信', '承辦人', 'Email', new Date(), activity.ownerEmail, '已寄送', '已確認', '測試信只寄給承辦人');
  updateDashboard_();
  alert_('測試信已寄給承辦人:' + activity.ownerEmail);
}

function recordNotificationStatus_(activity, name, target, channel, date, fileLink, status, confirmation, note) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  sheet.appendRow([
    activity.id,
    name,
    target,
    channel,
    date,
    nowText_(),
    fileLink || '',
    status || '已記錄',
    confirmation || '待確認',
    note || ''
  ]);
}

function refreshNotificationDocumentLinks_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  const map = buildHeaderMap_(sheet);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (String(readCellByHeader_(sheet, row, map, '活動ID')) !== String(activity.id)) continue;
    const name = String(readCellByHeader_(sheet, row, map, '通知名稱') || '');
    if (name.indexOf('家長活動通知') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentNoticeUrl);
    if (name.indexOf('家長同意書') >= 0 || name.indexOf('回收提醒') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentConsentUrl);
    if (name.indexOf('導師') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.teacherReminderUrl);
    if (name.indexOf('處室') >= 0 || name.indexOf('協調') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.officeEmailUrl);
  }
}


/**
 * ============================================================
 * DeadlineService.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:期限狀態與自動標記。
 */
function updateDeadlineStatuses() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  let updated = 0;

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const activity = readActivityFromRow_(sheet, row, map);
    if (!activity.name) continue;
    const registrationStatus = calculateDateStatus_(activity.registrationDeadline, false);
    const consentStatus = calculateDateStatus_(activity.consentDeadline, false);
    const activityStatus = calculateDateStatus_(activity.activityDate, false);
    const status = buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus);
    writeCellByHeader_(sheet, row, map, '目前狀態', status);
    writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
    updated++;
  }

  updateDashboard_();
  alert_('期限狀態已更新:' + updated + ' 筆活動。');
}

function buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus) {
  const urgent = [];
  if (registrationStatus === '逾期' || registrationStatus === '即將到期') urgent.push('報名' + registrationStatus);
  if (consentStatus === '逾期' || consentStatus === '即將到期') urgent.push('同意書' + consentStatus);
  if (activityStatus === '逾期') urgent.push('活動日期已過');
  if (activityStatus === '即將到期') urgent.push('活動即將到期');
  return urgent.length ? urgent.join(';') : '期限正常';
}

function buildDeadlineRows_(activity) {
  const activityDate = parseDate_(activity.activityDate);
  return [
    ['報名截止日', activity.registrationDeadline, '確認報名人數、未回覆名單與特殊需求'],
    ['同意書回收日', activity.consentDeadline, '確認同意書、保險與名冊'],
    ['活動前提醒日', addDays_(activityDate, -3), '確認導師、處室、家長與學生行前提醒'],
    ['成果整理日', addDays_(activityDate, 3), '整理照片、流程紀錄、回饋與附件'],
    ['NotebookLM 匯出日', addDays_(activityDate, 7), '去識別化後匯出資料包']
  ];
}


/**
 * ============================================================
 * NotebookExport.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:NotebookLM 資料包匯出。
 */
function exportNotebookLMPackageForSelectedActivity() {
  const packageContext = ensureActivityPackageForSelectedActivity_();
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;
  const content = buildNotebookMarkdown_(activity);
  const doc = createNotebookDoc_(activity, content, packageContext.subfolders['07_NotebookLM資料包']);

  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, 'NotebookLM資料包連結', doc.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
  appendDocumentListRow_(activity, 'NotebookLM資料包', doc.getName(), '匯入 NotebookLM 的去識別化專案摘要', doc.getUrl(), '系統匯出', '草稿', '待確認', '匯入前請再次檢查個資');
  updateDashboard_();
  alert_('NotebookLM 資料包已匯出:\n' + doc.getUrl());
  return doc.getUrl();
}

function buildNotebookMarkdown_(activity) {
  const ss = getActiveSpreadsheet_();
  const taskRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.TASKS), activity.id);
  const documentRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS), activity.id);
  const notificationRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS), activity.id);
  const outcomeRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES), activity.id);
  const sessionRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.SESSIONS), activity.id);
  const registrationStats = getRegistrationStatsByActivity_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS))[activity.id] || { total: 0, join: 0, notJoin: 0, special: 0 };

  return [
    '# NotebookLM匯入用|活動行政專案資料包',
    '',
    '## 一、活動基本資料',
    activityToText_(activity),
    '',
    '## 二、報名統計',
    '- 報名總人數:' + registrationStats.total,
    '- 參加人數:' + registrationStats.join,
    '- 不參加人數:' + registrationStats.notJoin,
    '- 特殊需求筆數:' + registrationStats.special,
    '- 注意:此處只放統計,不放學生電話、健康細節或身分資料。',
    '',
    '## 三、場次總覽',
    formatRowsForNotebook_(sessionRows, CAMP_SESSION_HEADERS, ['場次ID', '場次日期', '開始時間', '結束時間', '場次名稱', '場次主題', '講師', '地點或線上連結', '目前狀態', '報名人數', '簽到人數', '出席率']),
    '',
    '## 四、逐場任務狀態',
    formatSessionTaskSummary_(taskRows),
    '',
    '## 五、逐場成果整理',
    formatSessionOutcomeSummary_(outcomeRows),
    '',
    '## 六、多日活動行政觀察',
    '- 請檢查哪些場次報名人數偏低、簽到率偏低或課前任務未完成。',
    '- 請確認高風險場次是否已補上場地、講義、簽到、回饋與成果整理。',
    '- 若後續場次會受前一場成果影響,請在任務追蹤表補上前置任務。',
    '',
    '## 七、重要期限',
    buildDeadlineRows_(activity).map(function(row) {
      return '- ' + row[0] + ':' + formatDateValue_(row[1]) + ',' + row[2];
    }).join('\n'),
    '',
    '## 八、任務執行情形',
    formatRowsForNotebook_(taskRows, CAMP_TASK_HEADERS, ['階段', '任務名稱', '負責人', '建議完成日期', '狀態', '優先程度']),
    '',
    '## 九、通知紀錄',
    formatRowsForNotebook_(notificationRows, CAMP_NOTIFICATION_HEADERS, ['通知名稱', '通知對象', '通知管道', '建議通知日期', '寄送或通知狀態', '承辦人確認']),
    '',
    '## 十、文件清單',
    formatRowsForNotebook_(documentRows, CAMP_DOCUMENT_HEADERS, ['文件類型', '文件名稱', '用途', '連結', '狀態', '承辦人確認']),
    '',
    '## 十一、成果與檢查',
    formatRowsForNotebook_(outcomeRows, CAMP_OUTCOME_HEADERS, ['成果項目', '是否完成', '是否含個資', '是否可提供 NotebookLM', '負責人', '備註']),
    '',
    '## 十二、個資與匯出提醒',
    '- 不匯出學生電話、家長電話、健康細節、身分資料、完整名冊。',
    '- 不匯出完整簽到名冊、個別缺席原因或個別健康需求。',
    '- 需要使用學生回饋時,請先去識別化。',
    '- 正式公告、收費、保險、交通、安全與核銷仍由承辦人確認。',
    '',
    '## 十三、可問 NotebookLM 的問題範例',
    '1. 這個活動目前最需要先確認哪些事項?',
    '2. 哪些任務還沒有完成?',
    '3. 家長通知或導師提醒有哪些缺漏?',
    '4. 成果報告還缺哪些素材?',
    '5. 下次辦理有哪些可沿用與需調整事項?',
    '',
    '## 十四、AI 提示詞',
    CAMP_PROMPTS.notebook.replace('{{WORKFLOW_DATA}}', activityToText_(activity))
  ].join('\n');
}

function formatSessionTaskSummary_(taskRows) {
  if (!taskRows.length) return '目前尚無逐場任務。';
  const grouped = {};
  taskRows.forEach(function(row) {
    const sessionId = row[CAMP_TASK_HEADERS.indexOf('場次ID')] || 'ALL';
    if (!grouped[sessionId]) grouped[sessionId] = [];
    grouped[sessionId].push(row);
  });
  return Object.keys(grouped).sort().map(function(sessionId) {
    const rows = grouped[sessionId];
    const done = rows.filter(function(row) {
      return row[CAMP_TASK_HEADERS.indexOf('狀態')] === '已完成';
    }).length;
    return '- ' + sessionId + ':任務 ' + rows.length + ' 筆,已完成 ' + done + ' 筆,完成率 ' + formatPercent_(done, rows.length);
  }).join('\n');
}

function formatSessionOutcomeSummary_(outcomeRows) {
  if (!outcomeRows.length) return '目前尚無逐場成果資料。';
  const itemIndex = CAMP_OUTCOME_HEADERS.indexOf('成果項目');
  const statusIndex = CAMP_OUTCOME_HEADERS.indexOf('是否完成');
  return outcomeRows.filter(function(row) {
    return /^S\d{2}/.test(String(row[itemIndex] || ''));
  }).map(function(row) {
    return '- ' + row[itemIndex] + ':' + valueOrPending_(row[statusIndex]);
  }).join('\n') || '目前尚無逐場成果資料。';
}

function createNotebookDoc_(activity, content, folder) {
  const doc = DocumentApp.create(activity.id + '_NotebookLM資料包_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  content.split('\n').forEach(function(line) {
    if (line.indexOf('# ') === 0) {
      body.appendParagraph(line.replace(/^# /, '')).setHeading(DocumentApp.ParagraphHeading.TITLE);
    } else if (line.indexOf('## ') === 0) {
      body.appendParagraph(line.replace(/^## /, '')).setHeading(DocumentApp.ParagraphHeading.HEADING1);
    } else {
      body.appendParagraph(line);
    }
  });
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function formatRowsForNotebook_(rows, headers, selectedHeaders) {
  if (!rows.length) return '目前尚無資料。';
  return rows.map(function(row) {
    return '- ' + selectedHeaders.map(function(header) {
      const index = headers.indexOf(header);
      const value = index >= 0 ? row[index] : '';
      return header + ':' + valueOrPending_(formatDateIfNeeded_(value));
    }).join(';');
  }).join('\n');
}

function formatDateIfNeeded_(value) {
  if (value instanceof Date) return formatDateValue_(value);
  return value;
}


/**
 * ============================================================
 * SampleData.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:研習示範資料。
 */
function addNewTaipeiAiWorkshopSample() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);

  const activityId = 'ACT-NTPC-AI-11506';
  const activityName = '新北AI共創大聯盟|Google Workspace 行政專案工作流自動化實作';
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
  const existingRow = findActivityRowByIdOrName_(activitySheet, activityMap, activityId, activityName);

  if (existingRow) {
    const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
    ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
    if (getRowsByActivityId_(sessionSheet, activityId).length === 0) {
      appendNewTaipeiAiWorkshopSessions_(activityId);
      updateDashboard_();
      alert_('示範活動已存在,已補上缺漏的 4 筆場次設定。\n\n活動ID:' + activityId);
      return;
    }
    alert_('示範資料已存在,不重複新增。\n\n活動ID:' + activityId);
    return;
  }

  const startDate = new Date(2026, 5, 15);
  const endDate = new Date(2026, 5, 25);
  const registrationDeadline = new Date(2026, 5, 15);
  const activity = {};
  activity['活動ID'] = activityId;
  activity['活動名稱'] = activityName;
  activity['活動類型'] = '研習';
  activity['報名表模板類型'] = '線上研習';
  activity['活動日期'] = startDate;
  activity['活動開始日'] = startDate;
  activity['活動結束日'] = endDate;
  activity['每日預設開始時間'] = '19:00';
  activity['每日預設結束時間'] = '21:00';
  activity['活動天數'] = 4;
  activity['場次數'] = 4;
  activity['是否多日活動'] = '是';
  activity['是否開放部分場次報名'] = '是';
  activity['是否需要每日簽到'] = '是';
  activity['是否需要課後回饋'] = '是';
  activity['是否需要成果繳交'] = '否';
  activity['報名截止日'] = registrationDeadline;
  activity['同意書回收日'] = '';
  activity['活動地點'] = 'Google Meet 線上研習:https://meet.google.com/usb-mjie-ett';
  activity['參加對象'] = '新北市各校教師及行政人員';
  activity['預估人數'] = '';
  activity['承辦處室'] = '新北市政府教育局/新北AI共創大聯盟';
  activity['承辦人'] = '彭智標主任';
  activity['承辦人Email'] = Session.getActiveUser().getEmail() || '';
  activity['是否收費'] = '否';
  activity['費用說明'] = '線上研習,未載明收費';
  activity['交通方式'] = '線上研習無交通';
  activity['是否需要保險'] = '否';
  activity['是否需要家長同意書'] = '否';
  activity['報名表單連結'] = 'https://forms.gle/heMoe3KLF97RB6Hy5';
  activity['報名人數'] = 0;
  activity['同意參加'] = 0;
  activity['不參加'] = 0;
  activity['特殊需求數'] = 0;
  activity['目前狀態'] = '示範資料';
  activity['最近更新時間'] = nowText_();
  activity['備註'] = [
    '示範資料:新北AI研習。',
    '本筆資料已直接寫入活動清單與場次設定。',
    '若使用活動新增表單同步功能,系統應略過同名活動,避免重複建立。',
    '',
    '公文:新北教技字第1151036018號。',
    '研習名稱:「新北AI共創大聯盟」人工智慧增能研習—Google Workspace 行政專案工作流自動化實作。',
    '課程綱要:https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
    '研習主軸:從公文到成果,建立半自動行政專案管理工作流。',
    '正式公告、研習時數、簽到與行政作業仍依主辦單位規定辦理。'
  ].join('\n');

  activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) {
    return activity[header] || '';
  }));

  appendNewTaipeiAiWorkshopSessions_(activityId);
  updateDashboard_();

  alert_([
    '示範研習資料已新增完成。',
    '',
    '活動:' + activityName,
    '活動ID:' + activityId,
    '',
    '已新增 1 筆活動資料與 4 筆場次設定。',
    '請到「01_活動清單」選取這筆活動,再執行「從選取列建立活動資料包」。'
  ].join('\n'));
}

function appendNewTaipeiAiWorkshopSessions_(activityId) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);

  if (getRowsByActivityId_(sheet, activityId).length > 0) {
    return;
  }

  const sessions = [
    {
      sessionId: 'S01',
      date: new Date(2026, 5, 15),
      title: '第一場|從公文啟動專案',
      topic: '以教育局公文導入教學研習計畫為情境,學習判斷公文是否值得建立專案,並建立專案資料夾、追蹤表與成果檢核文件。'
    },
    {
      sessionId: 'S02',
      date: new Date(2026, 5, 18),
      title: '第二場|活動型專案管理',
      topic: '以校外教學/親子活動辦理為案例,拆解活動流程、建立階段性日期、設計報名表與回饋表。'
    },
    {
      sessionId: 'S03',
      date: new Date(2026, 5, 22),
      title: '第三場|成果型專案與AI文件整理',
      topic: '以補助計畫/成果報告為案例,建立 NotebookLM 專案筆記本,並運用 Gemini 輔助公告、摘要與成果報告草稿。'
    },
    {
      sessionId: 'S04',
      date: new Date(2026, 5, 25),
      title: '第四場|大型專案協作與制度化',
      topic: '以運動會/校慶大型活動為案例,學習跨處室分工、大量日期與任務管理、成果檢核與制度化。'
    }
  ];

  const rows = sessions.map(function(session) {
    return buildSessionSheetRow_({
      '活動ID': activityId,
      '場次ID': session.sessionId,
      '場次日期': session.date,
      '開始時間': '19:00',
      '結束時間': '21:00',
      '場次名稱': session.title,
      '場次主題': session.topic,
      '場次目標': 'Google Workspace、Apps Script、NotebookLM、Gemini 行政工作流實作',
      '講師': '彭智標主任',
      '助教或協作人員': '新北市立泰山高中協助簽到與研習時數行政作業',
      '地點或線上連結': 'Google Meet:https://meet.google.com/usb-mjie-ett',
      '本場需要設備': 'Google 帳號、可操作 Google Workspace 的電腦、近期實際行政專案案例',
      '講義連結': 'https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
      '報名人數': 0,
      '簽到人數': 0,
      '出席率': '0%',
      '課前任務完成率': '0%',
      '課後回饋數': 0,
      '成果整理狀態': '未整理',
      '風險等級': '低',
      '目前狀態': '準備中',
      '備註': '由新北研習公文建立之示範場次',
      '任務完成率': '0%',
      '任務完成狀態': '未開始'
    });
  });

  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
}

function findActivityRowByIdOrName_(sheet, headerMap, activityId, activityName) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
    const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
    if (existingId === activityId || existingName === activityName) {
      return row;
    }
  }
  return 0;
}


/**
 * ============================================================
 * MenuFlows.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:選單流程入口與相容函式。
 */
function updateDashboard() {
  updateDashboard_();
  alert_('中控台已重新整理。');
}

function createOfficialRegistrationFormForSelectedActivity() {
  return createRegistrationFormForSelectedActivity();
}

function syncCalendarStatusBackToTasks() {
  return syncCalendarBackToTaskOverview();
}

function runOfficialActivitySetupFlow() {
  const context = getSelectedActivityRowContext_();
  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  generateSessionsForActivity_(context.activity);
  updateActivitySessionFields_(context);
  appendDefaultTaskRows_(context.activity);
  appendDefaultNotificationRows_(context.activity);
  appendDefaultOutcomeRows_(context.activity);
  appendNotebookIndexRows_(context.activity);
  refreshTaskOverview();
  updateDeadlineStatuses();
  updateDashboard_();
  alert_([
    '正式活動骨架已建立或確認完成。',
    '',
    '活動:' + context.activity.name,
    '資料夾:' + packageContext.folder.getUrl(),
    '',
    '已確認:資料包、場次、任務、通知、成果與 NotebookLM 索引。'
  ].join('\n'));
}

function runDemoSessionAttendanceFlow() {
  const context = getSelectedActivityRowContext_();
  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  const sessions = generateSessionsForActivity_(context.activity);
  updateActivitySessionFields_(context);
  const attendanceRows = generateAttendanceRowsForActivity_(context.activity.id);
  const formResult = createAttendanceFormsForActivityContext_(context);
  updateSessionStats_();
  updateDashboard_();
  alert_([
    '一鍵展示流程已完成。',
    '',
    '活動:' + context.activity.name,
    '場次數:' + sessions.length,
    '新增簽到預備資料:' + attendanceRows + ' 筆',
    '新建立簽到表單:' + formResult.created + ' 張',
    '已存在略過:' + formResult.skipped + ' 張',
    '',
    '資料包:' + packageContext.folder.getUrl()
  ].join('\n'));
}

function createOutcomeChecklistSheetForSelectedActivity() {
  const packageContext = ensureActivityPackageForSelectedActivity_();
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;
  const outcomeSheet = createOutcomeChecklistSheet_(activity, packageContext.subfolders['06_成果檢查與結案']);
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
  appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '檢查活動後成果、照片、附件與 NotebookLM 可用資料', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
  updateDashboard_();
  alert_('成果檢查表已建立:\n' + outcomeSheet.getUrl());
  return outcomeSheet.getUrl();
}

function runOutcomeCloseoutFlow() {
  const packageContext = ensureActivityPackageForSelectedActivity_();
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;
  const outcomeSheet = createOutcomeChecklistSheet_(activity, packageContext.subfolders['06_成果檢查與結案']);
  const notebookContent = buildNotebookMarkdown_(activity);
  const notebookDoc = createNotebookDoc_(activity, notebookContent, packageContext.subfolders['07_NotebookLM資料包']);
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, 'NotebookLM資料包連結', notebookDoc.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '目前狀態', '成果結案資料已整理');
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
  appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '活動後成果、照片、附件與 NotebookLM 可用資料檢查', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, 'NotebookLM資料包', notebookDoc.getName(), '匯入 NotebookLM 的去識別化專案摘要', notebookDoc.getUrl(), '系統匯出', '草稿', '待確認', '匯入前請再次檢查個資');
  refreshTaskOverview();
  updateDashboard_();
  alert_([
    '成果與結案資料已整理。',
    '',
    '成果檢查表:' + outcomeSheet.getUrl(),
    'NotebookLM 資料包:' + notebookDoc.getUrl()
  ].join('\n'));
}


/**
 * ============================================================
 * Code.gs
 * ============================================================
 */
/**
 * 營隊/活動管理中樞:選單與主要入口。
 */
function onOpen() {
  buildCampAdminMenu_();
}

function onEdit(e) {
  handleTaskOverviewStatusEdit_(e);
}

function buildCampAdminMenu_() {
  try {
    const ui = SpreadsheetApp.getUi();

    const demoMenu = ui.createMenu('一、研習展示')
      .addItem('初始化展示環境', 'setupCampAdminHub')
      .addItem('新增示範資料:新北AI研習', 'addNewTaipeiAiWorkshopSample')
      .addItem('建立報名表單+50筆假資料', 'createRegistrationFormAndSeedSampleData')
      .addItem('一鍵展示:場次、簽到、統計', 'runDemoSessionAttendanceFlow')
      .addItem('匯出展示用 NotebookLM 資料包', 'exportNotebookLMPackageForSelectedActivity');

    const activityMenu = ui.createMenu('二、正式活動建案')
      .addItem('建立/開啟活動新增表單', 'createActivityIntakeForm')
      .addItem('匯入活動表單資料', 'syncActivityIntakeResponsesToActivityList')
      .addItem('為選取活動建立資料包', 'createActivityPackageFromSelectedRow')
      .addItem('一鍵建立正式活動骨架', 'runOfficialActivitySetupFlow')
      .addItem('重新整理中控台', 'updateDashboard');

    const registrationMenu = ui.createMenu('三、報名管理')
      .addItem('建立正式報名表單', 'createOfficialRegistrationFormForSelectedActivity')
      .addItem('建立報名表單+50筆假資料(研習用)', 'createRegistrationFormAndSeedSampleData')
      .addItem('更新報名名冊與統計', 'syncRegistrationResponses');

    const sessionMenu = ui.createMenu('四、場次與簽到')
      .addItem('產生/更新場次設定', 'generateSessionsForSelectedActivity')
      .addItem('更新場次統計', 'updateSessionStats')
      .addItem('建立各場次應到名單', 'generateAttendanceRowsForSelectedActivity')
      .addItem('產生場次簽到表單', 'createAttendanceFormsForSelectedActivity')
      .addItem('比對簽到回應並更新出席狀態', 'syncAttendanceFormResponses');

    const taskMenu = ui.createMenu('五、任務、期限與日曆')
      .addItem('更新任務進度與重要行事', 'refreshTaskOverview')
      .addItem('檢查期限與逾期風險', 'updateDeadlineStatuses')
      .addItem('同步重要任務到 Google 日曆', 'syncImportantTasksToCalendar')
      .addItem('從 Google 日曆回寫任務狀態', 'syncCalendarStatusBackToTasks')
      .addItem('建立日曆同步觸發器', 'createCalendarSyncTrigger')
      .addItem('移除日曆同步觸發器', 'removeCalendarSyncTrigger')
      .addItem('更新中控台燈號', 'updateDashboard');

    const docMenu = ui.createMenu('六、行政文件與通知')
      .addItem('建立行政文件模板', 'createDocumentTemplatesForSelectedActivity')
      .addItem('寄送測試信給承辦人', 'sendTestEmailToOwner');

    const outcomeMenu = ui.createMenu('七、成果與結案')
      .addItem('建立成果檢查表', 'createOutcomeChecklistSheetForSelectedActivity')
      .addItem('一鍵整理成果與結案資料', 'runOutcomeCloseoutFlow')
      .addItem('匯出 AI 成果整理資料包', 'exportNotebookLMPackageForSelectedActivity');

    const systemMenu = ui.createMenu('八、系統維護與說明')
      .addItem('建立/修復中控台環境', 'setupCampAdminHub')
      .addItem('重新整理中控台', 'updateDashboard')
      .addItem('開啟操作說明', 'showAboutCampAdminHub');

    ui.createMenu('營隊活動管理')
      .addSubMenu(demoMenu)
      .addSubMenu(activityMenu)
      .addSubMenu(registrationMenu)
      .addSubMenu(sessionMenu)
      .addSubMenu(taskMenu)
      .addSubMenu(docMenu)
      .addSubMenu(outcomeMenu)
      .addSubMenu(systemMenu)
      .addToUi();
  } catch (error) {
    Logger.log('建立選單失敗:' + error.message);
  }
}

function setupCampAdminHub() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  createActivityIntakeForm_();
  updateDashboard_();
  alert_('營隊活動管理中樞初始化完成。已建立活動新增表單,請從「00_中控台」開啟表單填寫,或到「01_活動清單」手動新增活動。');
}

function showAboutCampAdminHub() {
  alert_([
    CAMP_CONFIG.SYSTEM_NAME,
    '',
    '選單已依使用情境分組:',
    '一、研習展示:初始化展示環境、示範資料、假報名資料與展示流程。',
    '二、正式活動建案:活動新增表單、匯入資料、建立資料包與正式活動骨架。',
    '三、報名管理:正式報名表、研習用假資料、報名名冊與統計。',
    '四、場次與簽到:場次設定、應到名單、簽到表單與出席狀態。',
    '五、任務、期限與日曆:任務總覽、期限風險、Google 日曆雙向同步。',
    '六、行政文件與通知:行政文件模板與承辦人測試信。',
    '七、成果與結案:成果檢查、結案資料與 NotebookLM 資料包。',
    '八、系統維護與說明:修復中控台、重新整理中控台與操作說明。',
    '',
    '建議先選擇流程:研習展示請從「一、研習展示」開始;正式活動請從「二、正式活動建案」開始。',
    '',
    CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
  ].join('\n'));
}

六、分檔學習版

以下是同一套程式的分檔版本,用來講解架構與後續維護。若已貼完整一次貼上版,Apps Script 專案內不需要再重複貼這些分檔程式碼。

檔案 01

Code.gs|入口選單與主流程

/**
 * 營隊/活動管理中樞:選單與主要入口。
 */
function onOpen() {
  buildCampAdminMenu_();
}

function onEdit(e) {
  handleTaskOverviewStatusEdit_(e);
}

function buildCampAdminMenu_() {
  try {
    const ui = SpreadsheetApp.getUi();

    const demoMenu = ui.createMenu('一、研習展示')
      .addItem('初始化展示環境', 'setupCampAdminHub')
      .addItem('新增示範資料:新北AI研習', 'addNewTaipeiAiWorkshopSample')
      .addItem('建立報名表單+50筆假資料', 'createRegistrationFormAndSeedSampleData')
      .addItem('一鍵展示:場次、簽到、統計', 'runDemoSessionAttendanceFlow')
      .addItem('匯出展示用 NotebookLM 資料包', 'exportNotebookLMPackageForSelectedActivity');

    const activityMenu = ui.createMenu('二、正式活動建案')
      .addItem('建立/開啟活動新增表單', 'createActivityIntakeForm')
      .addItem('匯入活動表單資料', 'syncActivityIntakeResponsesToActivityList')
      .addItem('為選取活動建立資料包', 'createActivityPackageFromSelectedRow')
      .addItem('一鍵建立正式活動骨架', 'runOfficialActivitySetupFlow')
      .addItem('重新整理中控台', 'updateDashboard');

    const registrationMenu = ui.createMenu('三、報名管理')
      .addItem('建立正式報名表單', 'createOfficialRegistrationFormForSelectedActivity')
      .addItem('建立報名表單+50筆假資料(研習用)', 'createRegistrationFormAndSeedSampleData')
      .addItem('更新報名名冊與統計', 'syncRegistrationResponses');

    const sessionMenu = ui.createMenu('四、場次與簽到')
      .addItem('產生/更新場次設定', 'generateSessionsForSelectedActivity')
      .addItem('更新場次統計', 'updateSessionStats')
      .addItem('建立各場次應到名單', 'generateAttendanceRowsForSelectedActivity')
      .addItem('產生場次簽到表單', 'createAttendanceFormsForSelectedActivity')
      .addItem('比對簽到回應並更新出席狀態', 'syncAttendanceFormResponses');

    const taskMenu = ui.createMenu('五、任務、期限與日曆')
      .addItem('更新任務進度與重要行事', 'refreshTaskOverview')
      .addItem('檢查期限與逾期風險', 'updateDeadlineStatuses')
      .addItem('同步重要任務到 Google 日曆', 'syncImportantTasksToCalendar')
      .addItem('從 Google 日曆回寫任務狀態', 'syncCalendarStatusBackToTasks')
      .addItem('建立日曆同步觸發器', 'createCalendarSyncTrigger')
      .addItem('移除日曆同步觸發器', 'removeCalendarSyncTrigger')
      .addItem('更新中控台燈號', 'updateDashboard');

    const docMenu = ui.createMenu('六、行政文件與通知')
      .addItem('建立行政文件模板', 'createDocumentTemplatesForSelectedActivity')
      .addItem('寄送測試信給承辦人', 'sendTestEmailToOwner');

    const outcomeMenu = ui.createMenu('七、成果與結案')
      .addItem('建立成果檢查表', 'createOutcomeChecklistSheetForSelectedActivity')
      .addItem('一鍵整理成果與結案資料', 'runOutcomeCloseoutFlow')
      .addItem('匯出 AI 成果整理資料包', 'exportNotebookLMPackageForSelectedActivity');

    const systemMenu = ui.createMenu('八、系統維護與說明')
      .addItem('建立/修復中控台環境', 'setupCampAdminHub')
      .addItem('重新整理中控台', 'updateDashboard')
      .addItem('開啟操作說明', 'showAboutCampAdminHub');

    ui.createMenu('營隊活動管理')
      .addSubMenu(demoMenu)
      .addSubMenu(activityMenu)
      .addSubMenu(registrationMenu)
      .addSubMenu(sessionMenu)
      .addSubMenu(taskMenu)
      .addSubMenu(docMenu)
      .addSubMenu(outcomeMenu)
      .addSubMenu(systemMenu)
      .addToUi();
  } catch (error) {
    Logger.log('建立選單失敗:' + error.message);
  }
}

function setupCampAdminHub() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  createActivityIntakeForm_();
  updateDashboard_();
  alert_('營隊活動管理中樞初始化完成。已建立活動新增表單,請從「00_中控台」開啟表單填寫,或到「01_活動清單」手動新增活動。');
}

function showAboutCampAdminHub() {
  alert_([
    CAMP_CONFIG.SYSTEM_NAME,
    '',
    '選單已依使用情境分組:',
    '一、研習展示:初始化展示環境、示範資料、假報名資料與展示流程。',
    '二、正式活動建案:活動新增表單、匯入資料、建立資料包與正式活動骨架。',
    '三、報名管理:正式報名表、研習用假資料、報名名冊與統計。',
    '四、場次與簽到:場次設定、應到名單、簽到表單與出席狀態。',
    '五、任務、期限與日曆:任務總覽、期限風險、Google 日曆雙向同步。',
    '六、行政文件與通知:行政文件模板與承辦人測試信。',
    '七、成果與結案:成果檢查、結案資料與 NotebookLM 資料包。',
    '八、系統維護與說明:修復中控台、重新整理中控台與操作說明。',
    '',
    '建議先選擇流程:研習展示請從「一、研習展示」開始;正式活動請從「二、正式活動建案」開始。',
    '',
    CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
  ].join('\n'));
}

檔案 02

Config.gs|集中設定與欄位常數

/**
 * 營隊/活動管理中樞:集中設定與欄位常數。
 *
 * 教學重點:
 * 1. 所有工作表名稱、欄位、資料夾名稱集中管理。
 * 2. 後續模組只引用這裡的設定,不把欄位名稱寫散在各處。
 */
const CAMP_CONFIG = {
  SYSTEM_NAME: '營隊活動管理中樞',
  ROOT_FOLDER_ID: '',
  CALENDAR_ID: '',
  TIMEZONE: 'Asia/Taipei',
  DATE_SOON_DAYS: 7,
  HUMAN_CONFIRMATION_NOTICE: '本系統協助建檔、整理、提醒與產出草稿。正式公告、收費、保險、交通、安全、個資、採購與核銷仍須由承辦人或權責單位確認。'
};

const CAMP_SHEETS = {
  DASHBOARD: '00_中控台',
  ACTIVITIES: '01_活動清單',
  SESSIONS: '02_場次設定',
  REGISTRATIONS: '03_報名資料',
  ATTENDANCE: '04_場次簽到',
  TASK_OVERVIEW: '05_任務總覽',
  TASKS: '05_任務追蹤',
  DOCUMENTS: '06_文件清單',
  NOTIFICATIONS: '07_通知管理',
  OUTCOMES: '08_成果檢查',
  NOTEBOOK: '09_NotebookLM匯出',
  SETTINGS: '10_系統設定'
};

const CAMP_SHEET_ORDER = [
  CAMP_SHEETS.DASHBOARD,
  CAMP_SHEETS.ACTIVITIES,
  CAMP_SHEETS.SESSIONS,
  CAMP_SHEETS.REGISTRATIONS,
  CAMP_SHEETS.ATTENDANCE,
  CAMP_SHEETS.TASK_OVERVIEW,
  CAMP_SHEETS.TASKS,
  CAMP_SHEETS.DOCUMENTS,
  CAMP_SHEETS.NOTIFICATIONS,
  CAMP_SHEETS.OUTCOMES,
  CAMP_SHEETS.NOTEBOOK,
  CAMP_SHEETS.SETTINGS
];

const CAMP_ACTIVITY_HEADERS = [
  '活動ID',
  '活動名稱',
  '活動類型',
  '活動日期',
  '活動開始日',
  '活動結束日',
  '每日預設開始時間',
  '每日預設結束時間',
  '活動天數',
  '場次數',
  '是否多日活動',
  '是否開放部分場次報名',
  '是否需要每日簽到',
  '是否需要課後回饋',
  '是否需要成果繳交',
  '報名截止日',
  '同意書回收日',
  '活動地點',
  '參加對象',
  '預估人數',
  '承辦處室',
  '承辦人',
  '承辦人Email',
  '是否收費',
  '費用說明',
  '交通方式',
  '是否需要保險',
  '是否需要家長同意書',
  '專案資料夾連結',
  '報名表單連結',
  '報名表單編輯連結',
  '報名回應表連結',
  '活動摘要連結',
  '家長通知書連結',
  '家長同意書連結',
  '標案需求表連結',
  '處室通知草稿連結',
  '導師行前提醒連結',
  '成果檢查表連結',
  'NotebookLM資料包連結',
  '報名人數',
  '同意參加',
  '不參加',
  '特殊需求數',
  '目前狀態',
  '最近更新時間',
  '備註',
  '報名表模板類型',
  '任務完成率'
];

const CAMP_SESSION_HEADERS = [
  '活動ID',
  '場次ID',
  '場次日期',
  '開始時間',
  '結束時間',
  '場次名稱',
  '場次主題',
  '場次目標',
  '講師',
  '助教或協作人員',
  '地點或線上連結',
  '本場需要設備',
  '講義連結',
  '簽到表單連結',
  '回饋表單連結',
  '錄影連結',
  '報名人數',
  '簽到人數',
  '出席率',
  '課前任務完成率',
  '課後回饋數',
  '成果整理狀態',
  '風險等級',
  '目前狀態',
  '備註',
  '任務完成率',
  '任務完成狀態'
];

const CAMP_REGISTRATION_HEADERS = [
  '活動ID',
  '活動名稱',
  '時間戳記',
  '學生姓名',
  '班級',
  '座號',
  '家長姓名',
  '家長Email',
  '家長電話',
  '是否參加',
  '是否全程參加',
  '選擇參加場次',
  '需要研習時數',
  '學習期待',
  '目前行政痛點',
  '是否同意通知',
  '飲食需求',
  '特殊需求',
  '同意書狀態',
  '是否可提供 NotebookLM',
  '來源分頁',
  '備註'
];

const CAMP_ATTENDANCE_HEADERS = [
  '活動ID',
  '場次ID',
  '場次名稱',
  '場次日期',
  '學生或學員姓名',
  '學校或單位',
  'Email',
  '報名資料Key',
  '是否報名本場',
  '簽到狀態',
  '簽到時間',
  '簽退狀態',
  '簽退時間',
  '可核發時數',
  '備註'
];

const CAMP_TASK_HEADERS = [
  '活動ID',
  '場次ID',
  'WBS編碼',
  '階段',
  '任務名稱',
  '具體任務',
  '交付物',
  '完成判準',
  '負責角色',
  '負責人',
  '建議開始日期',
  '建議完成日期',
  '實際完成日期',
  '前置任務ID',
  '是否關鍵任務',
  '需要文件或表單',
  '狀態',
  '優先程度',
  '卡關原因',
  '備註',
  '最後更新時間'
];

const CAMP_TASK_OVERVIEW_HEADERS = [
  '任務ID',
  '活動ID',
  '活動名稱',
  '場次ID',
  '場次名稱',
  '階段',
  '任務名稱',
  '任務類型',
  '負責人',
  '截止日',
  '到期日',
  '狀態',
  '優先程度',
  '卡關原因/下一步',
  '備註',
  '重要行事',
  '日曆標題',
  '日曆日期',
  '開始時間',
  '結束時間',
  '日曆事件ID',
  '日曆同步狀態',
  '最後同步時間',
  '日曆回寫狀態',
  '日曆最後讀取時間',
  '完成時間',
  '完成日',
  '最後更新時間'
];

const CAMP_DOCUMENT_HEADERS = [
  '活動ID',
  '活動名稱',
  '文件類型',
  '文件名稱',
  '用途',
  '連結',
  '產生方式',
  '狀態',
  '最後更新時間',
  '承辦人確認',
  '備註'
];

const CAMP_NOTIFICATION_HEADERS = [
  '活動ID',
  '通知名稱',
  '通知對象',
  '通知管道',
  '建議通知日期',
  '實際通知日期',
  '文件連結',
  '寄送或通知狀態',
  '承辦人確認',
  '備註'
];

const CAMP_OUTCOME_HEADERS = [
  '活動ID',
  '成果項目',
  '是否完成',
  '檔案或資料夾連結',
  '是否含個資',
  '是否可提供 NotebookLM',
  '負責人',
  '備註'
];

const CAMP_NOTEBOOK_HEADERS = [
  '活動ID',
  '資料區塊',
  '內容摘要',
  '來源文件或工作表',
  '是否納入匯出',
  '備註'
];

const CAMP_SETTINGS_HEADERS = ['設定項目', '設定值', '說明'];

const CAMP_SUBFOLDER_NAMES = [
  '00_活動基本資料',
  '01_報名表單與回覆',
  '02_家長通知與同意書',
  '03_處室聯繫與採購',
  '04_任務追蹤與會議紀錄',
  '05_活動照片與成果',
  '06_成果檢查與結案',
  '07_NotebookLM資料包',
  '99_系統產生文件'
];

const CAMP_TASK_STATUS_VALUES = ['未開始', '進行中', '待確認', '已完成', '延後', '取消', '不適用'];
const CAMP_PRIORITY_VALUES = ['高', '中', '低'];
const CAMP_TASK_STATUS_COLORS = {
  '未開始': '#f3f4f6',
  '進行中': '#dbeafe',
  '待確認': '#fef3c7',
  '已完成': '#dcfce7',
  '延後': '#fee2e2',
  '取消': '#f5f5f5',
  '不適用': '#ede9fe'
};
const CAMP_PRIORITY_COLORS = {
  '高': '#fecaca',
  '中': '#fde68a',
  '低': '#bbf7d0'
};
const CAMP_OVERDUE_ROW_COLOR = '#fee2e2';
const CAMP_SOON_ROW_COLOR = '#fef9c3';
const CAMP_CONFIRM_VALUES = ['未確認', '待確認', '已確認', '退回修改', '不適用'];
const CAMP_YES_NO_VALUES = ['是', '否', '待確認', '不適用'];
const CAMP_DATE_STATUS_VALUES = ['未到期', '即將到期', '逾期', '已完成', '無日期'];
const CAMP_SESSION_STATUS_VALUES = ['未開始', '準備中', '進行中', '已完成', '待補件', '取消'];
const CAMP_RISK_LEVEL_VALUES = ['低', '中', '高', '極高'];
const CAMP_ATTENDANCE_STATUS_VALUES = ['未簽到', '已簽到', '請假', '缺席', '不適用'];
const CAMP_REGISTRATION_TEMPLATE_TYPES = ['自動判斷', '教師研習', '線上研習', '學生營隊', '校外教學', '親子活動', '競賽活動', '自訂'];

檔案 03

Utilities.gs|共用工具函式

/**
 * 營隊/活動管理中樞:共用工具函式。
 */
function getActiveSpreadsheet_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  if (!ss) throw new Error('請先開啟已綁定 Apps Script 的 Google 試算表。');
  return ss;
}

function alert_(message) {
  try {
    SpreadsheetApp.getUi().alert(String(message));
  } catch (error) {
    Logger.log(String(message));
  }
}

function getOrCreateSheet_(ss, name) {
  return ss.getSheetByName(name) || ss.insertSheet(name);
}

function ensureHeaders_(sheet, headers) {
  const width = headers.length;
  const existing = sheet.getRange(1, 1, 1, Math.max(width, sheet.getLastColumn() || width)).getValues()[0];
  let changed = false;
  headers.forEach(function(header, index) {
    if (existing[index] !== header) changed = true;
  });
  if (changed) {
    sheet.getRange(1, 1, 1, width).setValues([headers]);
  }
  formatHeaderRow_(sheet, width);
  return buildHeaderMap_(sheet);
}

function formatHeaderRow_(sheet, columns) {
  sheet.getRange(1, 1, 1, columns)
    .setFontWeight('bold')
    .setBackground('#dbeafe')
    .setHorizontalAlignment('center')
    .setWrap(true);
  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, columns);
}

function buildHeaderMap_(sheet) {
  const lastColumn = sheet.getLastColumn();
  if (!lastColumn) return {};
  const headers = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
  const map = {};
  headers.forEach(function(header, index) {
    if (!header) return;
    map[String(header).trim()] = index + 1;
  });
  return map;
}

function readCellByHeader_(sheet, row, headerMap, header) {
  const column = headerMap[header];
  if (!column) return '';
  return sheet.getRange(row, column).getValue();
}

function writeCellByHeader_(sheet, row, headerMap, header, value) {
  const column = headerMap[header];
  if (!column) return;
  sheet.getRange(row, column).setValue(value);
}

function readRowObject_(sheet, row, headers) {
  const map = buildHeaderMap_(sheet);
  const object = {};
  headers.forEach(function(header) {
    object[header] = readCellByHeader_(sheet, row, map, header);
  });
  return object;
}

function getSelectedActivityRowContext_() {
  const ss = getActiveSpreadsheet_();
  const sheet = ss.getActiveSheet();
  if (sheet.getName() !== CAMP_SHEETS.ACTIVITIES) {
    throw new Error('請先到「' + CAMP_SHEETS.ACTIVITIES + '」選取一筆活動資料列。');
  }
  const row = sheet.getActiveCell().getRow();
  if (row <= 1) throw new Error('請選取活動資料列,不要選標題列。');
  const headerMap = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  const activity = readActivityFromRow_(sheet, row, headerMap);
  if (!activity.name) throw new Error('選取列缺少「活動名稱」。');
  return { ss: ss, sheet: sheet, row: row, headerMap: headerMap, activity: activity };
}

function readActivityFromRow_(sheet, row, headerMap) {
  return {
    id: String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim(),
    name: String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim(),
    type: String(readCellByHeader_(sheet, row, headerMap, '活動類型') || '').trim(),
    registrationTemplateType: String(readCellByHeader_(sheet, row, headerMap, '報名表模板類型') || '').trim(),
    activityDate: readCellByHeader_(sheet, row, headerMap, '活動日期'),
    startDate: readCellByHeader_(sheet, row, headerMap, '活動開始日'),
    endDate: readCellByHeader_(sheet, row, headerMap, '活動結束日'),
    defaultStartTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設開始時間') || '').trim(),
    defaultEndTime: String(readCellByHeader_(sheet, row, headerMap, '每日預設結束時間') || '').trim(),
    activityDays: readCellByHeader_(sheet, row, headerMap, '活動天數'),
    sessionCount: readCellByHeader_(sheet, row, headerMap, '場次數'),
    isMultiDay: String(readCellByHeader_(sheet, row, headerMap, '是否多日活動') || '').trim(),
    allowPartialSessions: String(readCellByHeader_(sheet, row, headerMap, '是否開放部分場次報名') || '').trim(),
    dailyAttendanceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要每日簽到') || '').trim(),
    feedbackRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要課後回饋') || '').trim(),
    outcomeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要成果繳交') || '').trim(),
    registrationDeadline: readCellByHeader_(sheet, row, headerMap, '報名截止日'),
    consentDeadline: readCellByHeader_(sheet, row, headerMap, '同意書回收日'),
    location: String(readCellByHeader_(sheet, row, headerMap, '活動地點') || '').trim(),
    participants: String(readCellByHeader_(sheet, row, headerMap, '參加對象') || '').trim(),
    estimatedCount: readCellByHeader_(sheet, row, headerMap, '預估人數'),
    department: String(readCellByHeader_(sheet, row, headerMap, '承辦處室') || '').trim(),
    owner: String(readCellByHeader_(sheet, row, headerMap, '承辦人') || '').trim(),
    ownerEmail: String(readCellByHeader_(sheet, row, headerMap, '承辦人Email') || '').trim(),
    chargeRequired: String(readCellByHeader_(sheet, row, headerMap, '是否收費') || '').trim(),
    feeNote: String(readCellByHeader_(sheet, row, headerMap, '費用說明') || '').trim(),
    transport: String(readCellByHeader_(sheet, row, headerMap, '交通方式') || '').trim(),
    insuranceRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要保險') || '').trim(),
    consentRequired: String(readCellByHeader_(sheet, row, headerMap, '是否需要家長同意書') || '').trim(),
    folderUrl: String(readCellByHeader_(sheet, row, headerMap, '專案資料夾連結') || '').trim(),
    registrationFormUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單連結') || '').trim(),
    registrationFormEditUrl: String(readCellByHeader_(sheet, row, headerMap, '報名表單編輯連結') || '').trim(),
    summaryDocUrl: String(readCellByHeader_(sheet, row, headerMap, '活動摘要連結') || '').trim(),
    parentNoticeUrl: String(readCellByHeader_(sheet, row, headerMap, '家長通知書連結') || '').trim(),
    parentConsentUrl: String(readCellByHeader_(sheet, row, headerMap, '家長同意書連結') || '').trim(),
    procurementUrl: String(readCellByHeader_(sheet, row, headerMap, '標案需求表連結') || '').trim(),
    officeEmailUrl: String(readCellByHeader_(sheet, row, headerMap, '處室通知草稿連結') || '').trim(),
    teacherReminderUrl: String(readCellByHeader_(sheet, row, headerMap, '導師行前提醒連結') || '').trim(),
    outcomeUrl: String(readCellByHeader_(sheet, row, headerMap, '成果檢查表連結') || '').trim(),
    notebookUrl: String(readCellByHeader_(sheet, row, headerMap, 'NotebookLM資料包連結') || '').trim(),
    note: String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim()
  };
}

function activityToText_(activity) {
  return [
    '活動ID:' + valueOrPending_(activity.id),
    '活動名稱:' + valueOrPending_(activity.name),
    '活動類型:' + valueOrPending_(activity.type),
    '報名表模板類型:' + valueOrPending_(activity.registrationTemplateType || '自動判斷'),
    '活動日期:' + formatDateValue_(activity.activityDate),
    '活動開始日:' + formatDateValue_(activity.startDate),
    '活動結束日:' + formatDateValue_(activity.endDate),
    '每日預設時間:' + valueOrPending_(activity.defaultStartTime || '09:00') + '-' + valueOrPending_(activity.defaultEndTime || '12:00'),
    '活動天數:' + valueOrPending_(activity.activityDays),
    '場次數:' + valueOrPending_(activity.sessionCount),
    '是否多日活動:' + valueOrPending_(activity.isMultiDay),
    '是否開放部分場次報名:' + valueOrPending_(activity.allowPartialSessions),
    '是否需要每日簽到:' + valueOrPending_(activity.dailyAttendanceRequired),
    '是否需要課後回饋:' + valueOrPending_(activity.feedbackRequired),
    '是否需要成果繳交:' + valueOrPending_(activity.outcomeRequired),
    '報名截止日:' + formatDateValue_(activity.registrationDeadline),
    '同意書回收日:' + formatDateValue_(activity.consentDeadline),
    '活動地點:' + valueOrPending_(activity.location),
    '參加對象:' + valueOrPending_(activity.participants),
    '預估人數:' + valueOrPending_(activity.estimatedCount),
    '承辦處室:' + valueOrPending_(activity.department),
    '承辦人:' + valueOrPending_(activity.owner),
    '承辦人Email:' + valueOrPending_(activity.ownerEmail),
    '是否收費:' + valueOrPending_(activity.chargeRequired),
    '費用說明:' + valueOrPending_(activity.feeNote),
    '交通方式:' + valueOrPending_(activity.transport),
    '是否需要保險:' + valueOrPending_(activity.insuranceRequired),
    '是否需要家長同意書:' + valueOrPending_(activity.consentRequired),
    '備註:' + valueOrPending_(activity.note),
    '',
    '場次摘要:',
    formatSessionsText_(activity.id)
  ].join('\n');
}

function generateActivityId_() {
  const dateText = Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd-HHmmss');
  const props = PropertiesService.getScriptProperties();
  const key = 'CAMP_ACTIVITY_SERIAL_' + Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyyMMdd');
  const next = Number(props.getProperty(key) || '0') + 1;
  props.setProperty(key, String(next));
  return 'ACT-' + dateText + '-' + ('000' + next).slice(-3);
}

function nowText_() {
  return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm:ss');
}

function nowMinuteText_() {
  return Utilities.formatDate(new Date(), CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm');
}

function parseDate_(value) {
  if (value instanceof Date && !isNaN(value.getTime())) return value;
  if (!value) return null;
  const date = new Date(value);
  return isNaN(date.getTime()) ? null : date;
}

function addDays_(date, days) {
  if (!date) return null;
  const copy = new Date(date.getTime());
  copy.setDate(copy.getDate() + days);
  return copy;
}

function formatDateValue_(value) {
  const date = parseDate_(value);
  if (!date) return value ? String(value) : '待確認';
  return Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd');
}

function effectiveActivityStartDate_(activity) {
  return parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}

function effectiveActivityEndDate_(activity) {
  return parseDate_(activity.endDate) || parseDate_(activity.startDate) || parseDate_(activity.activityDate);
}

function calculateActivityDays_(activity) {
  const startDate = effectiveActivityStartDate_(activity);
  const endDate = effectiveActivityEndDate_(activity);
  if (!startDate || !endDate) return '';
  const start = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
  const end = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
  return Math.max(1, Math.floor((end.getTime() - start.getTime()) / 86400000) + 1);
}

function formatPercent_(numerator, denominator) {
  if (!denominator) return '0%';
  return Math.round((Number(numerator || 0) / Number(denominator || 0)) * 100) + '%';
}

function isSameDate_(a, b) {
  const da = parseDate_(a);
  const db = parseDate_(b);
  if (!da || !db) return false;
  return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
}

function calculateDateStatus_(value, done) {
  if (done) return '已完成';
  const date = parseDate_(value);
  if (!date) return '無日期';
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const target = new Date(date.getTime());
  target.setHours(0, 0, 0, 0);
  const diffDays = Math.floor((target.getTime() - today.getTime()) / 86400000);
  if (diffDays < 0) return '逾期';
  if (diffDays <= CAMP_CONFIG.DATE_SOON_DAYS) return '即將到期';
  return '未到期';
}

function sanitizeFileName_(value) {
  return String(value || '未命名活動').replace(/[\\/:*?"<>|#%{}~&]/g, '_').slice(0, 120);
}

function valueOrPending_(value) {
  if (value === null || value === undefined || value === '') return '待確認';
  return String(value);
}

function extractIdFromUrl_(url) {
  const text = String(url || '');
  const match = text.match(/[-\w]{25,}/);
  return match ? match[0] : '';
}

function getRowsByActivityId_(sheet, activityId) {
  if (!sheet || sheet.getLastRow() <= 1) return [];
  const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
  const headers = values[0];
  const idIndex = headers.indexOf('活動ID');
  if (idIndex < 0) return [];
  return values.slice(1).filter(function(row) {
    return String(row[idIndex] || '') === String(activityId || '');
  });
}

function buildRowsByHeader_(sheet) {
  if (!sheet || sheet.getLastRow() <= 1) return [];
  const values = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()).getValues();
  const headers = values[0];
  return values.slice(1).map(function(row) {
    const object = {};
    headers.forEach(function(header, index) {
      if (header) object[String(header)] = row[index];
    });
    return object;
  });
}

function appendDocumentListRow_(activity, type, name, purpose, url, method, status, confirmation, note) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS);
  ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
  sheet.appendRow([
    activity.id,
    activity.name,
    type,
    name,
    purpose,
    url,
    method || '系統模板',
    status || '草稿',
    nowText_(),
    confirmation || '待確認',
    note || ''
  ]);
}

function setValidationList_(sheet, startRow, column, rowCount, values) {
  if (rowCount <= 0) return;
  const rule = SpreadsheetApp.newDataValidation()
    .requireValueInList(values, true)
    .setAllowInvalid(true)
    .build();
  sheet.getRange(startRow, column, rowCount, 1).setDataValidation(rule);
}

檔案 04

SheetSetup.gs|中控台與工作表初始化

/**
 * 營隊/活動管理中樞:中控 Spreadsheet 初始化。
 */
function initializeSheets_(ss) {
  CAMP_SHEET_ORDER.forEach(function(name) {
    getOrCreateSheet_(ss, name);
  });

  setupDashboardSheet_(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
  setupActivityListSheet_(ss.getSheetByName(CAMP_SHEETS.ACTIVITIES));
  setupSessionSheet_(ss.getSheetByName(CAMP_SHEETS.SESSIONS));
  setupRegistrationSheet_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS));
  setupAttendanceSheet_(ss.getSheetByName(CAMP_SHEETS.ATTENDANCE));
  setupTaskOverviewSheet_(ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW));
  setupTaskSheet_(ss.getSheetByName(CAMP_SHEETS.TASKS));
  setupDocumentListSheet_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS));
  setupNotificationSheet_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS));
  setupOutcomeSheet_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES));
  setupNotebookSheet_(ss.getSheetByName(CAMP_SHEETS.NOTEBOOK));
  setupSettingsSheet_(ss.getSheetByName(CAMP_SHEETS.SETTINGS));
  restoreActivityIntakeLinksToDashboard_(ss);

  CAMP_SHEET_ORDER.forEach(function(name, index) {
    ss.setActiveSheet(ss.getSheetByName(name));
    ss.moveActiveSheet(index + 1);
  });
  ss.setActiveSheet(ss.getSheetByName(CAMP_SHEETS.DASHBOARD));
}

function setupDashboardSheet_(sheet) {
  sheet.clear();
  sheet.getRange('A1').setValue(CAMP_CONFIG.SYSTEM_NAME).setFontSize(18).setFontWeight('bold');
  sheet.getRange('A3:D23').setValues([
    ['指標', '目前數值', '狀態', '提示'],
    ['活動總數', 0, '', ''],
    ['多日活動數', 0, '', ''],
    ['場次總數', 0, '', ''],
    ['報名總人數', 0, '', ''],
    ['場次簽到總筆數', 0, '', ''],
    ['平均出席率', '0%', '', ''],
    ['未完成任務數', 0, '', ''],
    ['今日到期任務', 0, '', ''],
    ['延宕任務數', 0, '', ''],
    ['高風險場次數', 0, '', ''],
    ['待確認文件數', 0, '', ''],
    ['NotebookLM 匯出數', 0, '', ''],
    ['最後更新時間', nowText_(), '', ''],
    ['重要行事總數', 0, '', ''],
    ['已同步日曆數', 0, '', ''],
    ['日曆回寫成功數', 0, '', ''],
    ['日曆事件遺失數', 0, '', ''],
    ['日曆同步失敗數', 0, '', ''],
    ['今日到期重要行事', 0, '', ''],
    ['延宕重要行事', 0, '', '']
  ]);
  formatDashboardKpiLayout_(sheet);

  sheet.getRange('F3:G7').setValues([
    ['快速連結', '連結或說明'],
    ['活動新增表單', '尚未建立'],
    ['活動新增表單編輯', '尚未建立'],
    ['操作提醒', '示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。'],
    ['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE]
  ]);
  sheet.getRange('F3:G3').setFontWeight('bold').setBackground('#dbeafe');
  sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
  sheet.getRange('G4:G7').setWrap(true);

  sheet.getRange('A25').setValue('場次進度總覽').setFontWeight('bold');
  sheet.getRange('A26:K26').setValues([['活動ID', '活動名稱', '場次ID', '場次日期', '場次名稱', '報名人數', '簽到人數', '出席率', '課前任務完成率', '目前狀態', '風險等級']]).setFontWeight('bold').setBackground('#e0f2fe');
  sheet.getRange('A38').setValue('紅燈任務').setFontWeight('bold');
  sheet.getRange('A39:G39').setValues([['活動ID', '場次ID', '任務名稱', '負責人', '建議完成日期', '狀態', '卡關原因']]).setFontWeight('bold').setBackground('#fee2e2');
  sheet.getRange('A53').setValue('今日/明日場次').setFontWeight('bold');
  sheet.getRange('A54:G54').setValues([['日期', '活動名稱', '場次ID', '場次名稱', '時間', '地點', '狀態']]).setFontWeight('bold').setBackground('#dcfce7');
  sheet.getRange('A68').setValue('下一步建議').setFontWeight('bold');
  sheet.getRange('A69').setValue('請先選擇一條流程:示範資料流程,或活動新增表單流程。示範資料已直接寫入活動清單,不需要再匯入活動新增表單回應。').setWrap(true);
  sheet.setColumnWidth(1, 220);
  sheet.setColumnWidth(2, 120);
  sheet.setColumnWidth(3, 130);
  sheet.setColumnWidth(4, 320);
  sheet.setColumnWidth(6, 180);
  sheet.setColumnWidth(7, 520);
}

function ensureDashboardVisualLayout_(sheet) {
  sheet.getRange('A3:D3').setValues([['指標', '目前數值', '狀態', '提示']]);
  sheet.getRange('E3:E7').clearContent().clearFormat();
  sheet.getRange('A4:A23').setValues([
    ['活動總數'],
    ['多日活動數'],
    ['場次總數'],
    ['報名總人數'],
    ['場次簽到總筆數'],
    ['平均出席率'],
    ['未完成任務數'],
    ['今日到期任務'],
    ['延宕任務數'],
    ['高風險場次數'],
    ['待確認文件數'],
    ['NotebookLM 匯出數'],
    ['最後更新時間'],
    ['重要行事總數'],
    ['已同步日曆數'],
    ['日曆回寫成功數'],
    ['日曆事件遺失數'],
    ['日曆同步失敗數'],
    ['今日到期重要行事'],
    ['延宕重要行事']
  ]);
  formatDashboardKpiLayout_(sheet);
  sheet.getRange('F3:G3').setValues([['快速連結', '連結或說明']]).setFontWeight('bold').setBackground('#dbeafe');
  sheet.getRange('F4:F7').setFontWeight('bold').setBackground('#f8fafc');
  sheet.getRange('G4:G7').setWrap(true);
  sheet.setColumnWidth(1, 220);
  sheet.setColumnWidth(2, 120);
  sheet.setColumnWidth(3, 130);
  sheet.setColumnWidth(4, 320);
  sheet.setColumnWidth(5, 28);
  sheet.setColumnWidth(6, 180);
  sheet.setColumnWidth(7, 520);
}

function formatDashboardKpiLayout_(sheet) {
  sheet.getRange('A3:D3')
    .setFontWeight('bold')
    .setBackground('#dbeafe')
    .setHorizontalAlignment('center');
  sheet.getRange('A4:A23').setFontWeight('bold').setBackground('#f8fafc');
  sheet.getRange('B4:B23').setHorizontalAlignment('right');
  sheet.getRange('C4:C23').setHorizontalAlignment('center');
  sheet.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}

function setupActivityListSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('報名表模板類型') + 1, 200, CAMP_REGISTRATION_TEMPLATE_TYPES);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否收費') + 1, 200, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要保險') + 1, 200, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_ACTIVITY_HEADERS.indexOf('是否需要家長同意書') + 1, 200, CAMP_YES_NO_VALUES);
}

function setupTaskOverviewSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
  formatTaskOverviewHeader_(sheet);
}

function setupSessionSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('目前狀態') + 1, 500, CAMP_SESSION_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_SESSION_HEADERS.indexOf('風險等級') + 1, 500, CAMP_RISK_LEVEL_VALUES);
}

function setupRegistrationSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
}

function setupAttendanceSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
  setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽到狀態') + 1, 1000, CAMP_ATTENDANCE_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_ATTENDANCE_HEADERS.indexOf('簽退狀態') + 1, 1000, ['未簽退', '已簽退', '不適用']);
}

function setupTaskSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_TASK_HEADERS);
  setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('狀態') + 1, 500, CAMP_TASK_STATUS_VALUES);
  setValidationList_(sheet, 2, CAMP_TASK_HEADERS.indexOf('優先程度') + 1, 500, CAMP_PRIORITY_VALUES);
}

function setupDocumentListSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_DOCUMENT_HEADERS);
  setValidationList_(sheet, 2, CAMP_DOCUMENT_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}

function setupNotificationSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  setValidationList_(sheet, 2, CAMP_NOTIFICATION_HEADERS.indexOf('承辦人確認') + 1, 500, CAMP_CONFIRM_VALUES);
}

function setupOutcomeSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
  setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否完成') + 1, 500, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否含個資') + 1, 500, CAMP_YES_NO_VALUES);
  setValidationList_(sheet, 2, CAMP_OUTCOME_HEADERS.indexOf('是否可提供 NotebookLM') + 1, 500, CAMP_YES_NO_VALUES);
}

function setupNotebookSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
  setValidationList_(sheet, 2, CAMP_NOTEBOOK_HEADERS.indexOf('是否納入匯出') + 1, 500, CAMP_YES_NO_VALUES);
}

function setupSettingsSheet_(sheet) {
  ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
  if (sheet.getLastRow() === 1) {
    sheet.getRange(2, 1, 7, 3).setValues([
      ['SYSTEM_NAME', CAMP_CONFIG.SYSTEM_NAME, '系統名稱'],
      ['ROOT_FOLDER_ID', CAMP_CONFIG.ROOT_FOLDER_ID || '空白時使用目前試算表所在資料夾或我的雲端硬碟根目錄', '平台根資料夾設定'],
      ['CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定'],
      ['TIMEZONE', CAMP_CONFIG.TIMEZONE, '日期計算使用時區'],
      ['DATE_SOON_DAYS', CAMP_CONFIG.DATE_SOON_DAYS, '幾天內視為即將到期'],
      ['GEMINI_API_KEY', '未在本教學 MVP 啟用', 'API key 不寫入試算表'],
      ['安全提醒', CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE, '正式內容仍由承辦人確認']
    ]);
  }
  ensureSettingExists_(sheet, 'CALENDAR_ID', CAMP_CONFIG.CALENDAR_ID || '空白時使用預設日曆', '關鍵任務登記日曆設定');
}

function ensureSettingExists_(sheet, key, value, note) {
  const map = buildHeaderMap_(sheet);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (String(readCellByHeader_(sheet, row, map, '設定項目') || '') === String(key || '')) {
      return;
    }
  }
  sheet.appendRow([key, value, note]);
}

function updateDashboard_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
  ensureDashboardVisualLayout_(dashboard);
  restoreActivityIntakeLinksToDashboard_(ss);
  updateDashboardKpis_();
  updateDashboardStatusLights_();
  updateDashboardSessionOverview_();
  updateDashboardRedTasks_();
  updateDashboardTodaySessions_();
}

function restoreActivityIntakeLinksToDashboard_(ss) {
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
  const map = buildHeaderMap_(settings);
  if (!map['設定項目'] || !map['設定值']) return;

  const links = {};
  for (let row = 2; row <= settings.getLastRow(); row++) {
    const key = String(readCellByHeader_(settings, row, map, '設定項目') || '');
    if (key === '活動新增表單' || key === '活動新增表單編輯') {
      links[key] = readCellByHeader_(settings, row, map, '設定值');
    }
  }

  if (links['活動新增表單']) {
    dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
    dashboard.getRange('G4').setValue(links['活動新增表單']).setWrap(true);
  }
  if (links['活動新增表單編輯']) {
    dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
    dashboard.getRange('G5').setValue(links['活動新增表單編輯']).setWrap(true);
  }
}

檔案 05

DriveService.gs|Drive 資料夾管理

/**
 * 營隊/活動管理中樞:Drive 資料夾操作。
 */
function getCampRootFolder_() {
  if (CAMP_CONFIG.ROOT_FOLDER_ID) {
    try {
      return DriveApp.getFolderById(CAMP_CONFIG.ROOT_FOLDER_ID);
    } catch (error) {
      Logger.log('ROOT_FOLDER_ID 無效,改用試算表所在資料夾:' + error.message);
    }
  }

  const ss = getActiveSpreadsheet_();
  const file = DriveApp.getFileById(ss.getId());
  const parents = file.getParents();
  const parent = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
  return getOrCreateSubFolder_(parent, '營隊活動管理中樞_資料包');
}

function createActivityFolder_(activity) {
  const root = getCampRootFolder_();
  const dateText = formatDateValue_(activity.activityDate).replace(/\//g, '');
  const folderName = sanitizeFileName_((activity.id || generateActivityId_()) + '_' + dateText + '_' + activity.name);
  return root.createFolder(folderName);
}

function createActivitySubfolders_(folder) {
  const map = {};
  CAMP_SUBFOLDER_NAMES.forEach(function(name) {
    map[name] = getOrCreateSubFolder_(folder, name);
  });
  return map;
}

function getOrCreateSubFolder_(parent, name) {
  const folders = parent.getFoldersByName(name);
  return folders.hasNext() ? folders.next() : parent.createFolder(name);
}

function moveFileToFolder_(fileId, folder) {
  const file = DriveApp.getFileById(fileId);
  folder.addFile(file);
  const parents = file.getParents();
  while (parents.hasNext()) {
    const parent = parents.next();
    if (parent.getId() !== folder.getId()) {
      try {
        parent.removeFile(file);
      } catch (error) {
        Logger.log('移除原父資料夾失敗,可忽略:' + error.message);
      }
    }
  }
  return file;
}

function getFolderFromActivityUrl_(url) {
  const id = extractIdFromUrl_(url);
  if (!id) return null;
  try {
    return DriveApp.getFolderById(id);
  } catch (error) {
    Logger.log('無法由活動資料夾連結取得資料夾:' + error.message);
    return null;
  }
}

function getActivitySubfolderMap_(folder) {
  const map = {};
  CAMP_SUBFOLDER_NAMES.forEach(function(name) {
    map[name] = getOrCreateSubFolder_(folder, name);
  });
  return map;
}

檔案 06

SessionService.gs|多日場次、簽到與 Dashboard 統計

/**
 * 營隊/活動管理中樞:多日活動、場次與簽到服務。
 */
function generateSessionsForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  generateSessionsForActivity_(context.activity);
  updateActivitySessionFields_(context);
  updateSessionStats_();
  updateDashboard_();
  alert_('場次設定已產生或更新完成。');
}

function updateSessionStats() {
  updateSessionStats_();
  updateDashboard_();
  alert_('場次統計已更新。');
}

function generateAttendanceRowsForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  generateAttendanceRowsForActivity_(context.activity.id);
  updateSessionStats_();
  updateDashboard_();
  alert_('場次簽到預備資料已產生。');
}

function generateSessionsForActivity_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  if (!activity.id) return [];
  const existing = getSessionsByActivityId_(activity.id);
  if (existing.length > 0) return existing;

  const startDate = effectiveActivityStartDate_(activity);
  const endDate = effectiveActivityEndDate_(activity) || startDate;
  if (!startDate) return [];

  const rows = [];
  let index = 1;
  for (let d = new Date(startDate.getTime()); d <= endDate; d.setDate(d.getDate() + 1)) {
    const sessionId = 'S' + ('0' + index).slice(-2);
    rows.push(buildSessionSheetRow_({
      '活動ID': activity.id,
      '場次ID': sessionId,
      '場次日期': new Date(d.getTime()),
      '開始時間': activity.defaultStartTime || '09:00',
      '結束時間': activity.defaultEndTime || '12:00',
      '場次名稱': '第 ' + index + ' 日',
      '講師': activity.owner || '',
      '地點或線上連結': activity.location || '',
      '報名人數': 0,
      '簽到人數': 0,
      '出席率': '0%',
      '課前任務完成率': '0%',
      '課後回饋數': 0,
      '成果整理狀態': '未整理',
      '風險等級': '低',
      '目前狀態': '未開始',
      '備註': '',
      '任務完成率': '0%',
      '任務完成狀態': '未開始'
    }));
    index++;
  }
  if (rows.length) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
  }
  return getSessionsByActivityId_(activity.id);
}

function buildSessionSheetRow_(sessionData) {
  return CAMP_SESSION_HEADERS.map(function(header) {
    return Object.prototype.hasOwnProperty.call(sessionData, header) ? sessionData[header] : '';
  });
}

function getSessionsByActivityId_(activityId) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  const rows = buildRowsByHeader_(sheet).filter(function(row) {
    return String(row['活動ID'] || '') === String(activityId || '');
  });
  return rows.map(function(row) {
    return {
      activityId: row['活動ID'],
      sessionId: row['場次ID'],
      date: row['場次日期'],
      startTime: row['開始時間'],
      endTime: row['結束時間'],
      title: row['場次名稱'],
      topic: row['場次主題'],
      instructor: row['講師'],
      location: row['地點或線上連結'],
      registrationCount: row['報名人數'] || 0,
      checkinCount: row['簽到人數'] || 0,
      attendanceRate: row['出席率'] || '0%',
      preTaskRate: row['課前任務完成率'] || '0%',
      outcomeStatus: row['成果整理狀態'] || '',
      riskLevel: row['風險等級'] || '',
      status: row['目前狀態'] || ''
    };
  });
}

function formatSessionsText_(activityId) {
  if (!activityId) return '尚未建立場次。';
  try {
    const sessions = getSessionsByActivityId_(activityId);
    if (!sessions.length) return '尚未建立場次。';
    return sessions.map(function(session) {
      return [
        session.sessionId,
        formatDateValue_(session.date),
        valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
        valueOrPending_(session.title),
        valueOrPending_(session.topic),
        valueOrPending_(session.location),
        valueOrPending_(session.status)
      ].join('|');
    }).join('\n');
  } catch (error) {
    Logger.log('場次摘要讀取失敗:' + error.message);
    return '場次摘要讀取失敗。';
  }
}

function appendSessionsTable_(body, activityId) {
  const sessions = getSessionsByActivityId_(activityId);
  if (!sessions.length) {
    body.appendParagraph('尚未建立場次。');
    return;
  }
  const rows = [['場次ID', '日期', '時間', '場次名稱', '主題', '地點']];
  sessions.forEach(function(session) {
    rows.push([
      session.sessionId,
      formatDateValue_(session.date),
      valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
      valueOrPending_(session.title),
      valueOrPending_(session.topic),
      valueOrPending_(session.location)
    ]);
  });
  appendDocTable_(body, rows);
}

function updateSessionStats_() {
  const ss = getActiveSpreadsheet_();
  const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
  const sessionMap = buildHeaderMap_(sessionSheet);
  const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
  const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
  const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));

  for (let row = 2; row <= sessionSheet.getLastRow(); row++) {
    const activityId = readCellByHeader_(sessionSheet, row, sessionMap, '活動ID');
    const sessionId = readCellByHeader_(sessionSheet, row, sessionMap, '場次ID');
    if (!activityId || !sessionId) continue;
    const registered = registrationRows.filter(function(item) {
      return String(item['活動ID'] || '') === String(activityId)
        && selectedSessionsContain_(item['選擇參加場次'], sessionId);
    }).length;
    const checkedIn = attendanceRows.filter(function(item) {
      return String(item['活動ID'] || '') === String(activityId)
        && String(item['場次ID'] || '') === String(sessionId)
        && String(item['簽到狀態'] || '') === '已簽到';
    }).length;
    const preTasks = taskRows.filter(function(item) {
      return String(item['活動ID'] || '') === String(activityId)
        && String(item['場次ID'] || '') === String(sessionId)
        && String(item['階段'] || '').indexOf('課前') >= 0;
    });
    const donePreTasks = preTasks.filter(function(item) {
      return String(item['狀態'] || '') === '已完成';
    }).length;
    writeCellByHeader_(sessionSheet, row, sessionMap, '報名人數', registered);
    writeCellByHeader_(sessionSheet, row, sessionMap, '簽到人數', checkedIn);
    writeCellByHeader_(sessionSheet, row, sessionMap, '出席率', formatPercent_(checkedIn, registered));
    writeCellByHeader_(sessionSheet, row, sessionMap, '課前任務完成率', formatPercent_(donePreTasks, preTasks.length));
  }
  updateActivitySessionCounts_();
}

function updateActivitySessionCounts_() {
  const ss = getActiveSpreadsheet_();
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
  for (let row = 2; row <= activitySheet.getLastRow(); row++) {
    const activity = readActivityFromRow_(activitySheet, row, activityMap);
    if (!activity.id) continue;
    const sessionCount = getSessionsByActivityId_(activity.id).length;
    const days = calculateActivityDays_(activity);
    writeCellByHeader_(activitySheet, row, activityMap, '活動天數', days);
    writeCellByHeader_(activitySheet, row, activityMap, '場次數', sessionCount);
    if (!activity.isMultiDay || activity.isMultiDay === '待確認') {
      writeCellByHeader_(activitySheet, row, activityMap, '是否多日活動', Number(days || 0) > 1 ? '是' : '否');
    }
  }
}

function updateActivitySessionFields_(context) {
  const sessions = getSessionsByActivityId_(context.activity.id);
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動天數', calculateActivityDays_(context.activity));
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '場次數', sessions.length);
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '是否多日活動', sessions.length > 1 ? '是' : '否');
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
}

function selectedSessionsContain_(selectedText, sessionId) {
  return parseSelectedSessionIds_(selectedText).indexOf(String(sessionId || '')) >= 0;
}

function parseSelectedSessionIds_(text) {
  const raw = String(text || '');
  const direct = raw
    .split(/[,,\n]/)
    .map(function(part) {
      const match = String(part).match(/S\d{2}/);
      return match ? match[0] : '';
    })
    .filter(Boolean);

  const inferred = [];
  if (raw.indexOf('6月15日') >= 0 || raw.indexOf('6/15') >= 0) inferred.push('S01');
  if (raw.indexOf('6月18日') >= 0 || raw.indexOf('6/18') >= 0) inferred.push('S02');
  if (raw.indexOf('6月22日') >= 0 || raw.indexOf('6/22') >= 0) inferred.push('S03');
  if (raw.indexOf('6月25日') >= 0 || raw.indexOf('6/25') >= 0) inferred.push('S04');

  return direct.concat(inferred).filter(function(value, index, list) {
    return value && list.indexOf(value) === index;
  });
}

function buildRegistrationKey_(registration, sessionId) {
  return [
    registration.activityId || '',
    registration.email || '',
    registration.name || '',
    sessionId || ''
  ].join('|');
}

function ensureAttendanceRowsForRegistration_(registration) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
  ensureHeaders_(sheet, CAMP_ATTENDANCE_HEADERS);
  const sessions = getRegistrationSessions_(registration);
  if (!sessions.length) return 0;
  const existing = buildExistingAttendanceKeys_(sheet);
  const rows = [];
  sessions.forEach(function(session) {
    const key = buildRegistrationKey_(registration, session.sessionId);
    if (existing[key]) return;
    rows.push([
      registration.activityId,
      session.sessionId,
      session.title,
      session.date,
      registration.name,
      registration.unit,
      registration.email,
      key,
      '是',
      '未簽到',
      '',
      '未簽退',
      '',
      '',
      ''
    ]);
    existing[key] = true;
  });
  if (rows.length) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_ATTENDANCE_HEADERS.length).setValues(rows);
  }
  return rows.length;
}

function getRegistrationSessions_(registration) {
  const sessions = getSessionsByActivityId_(registration.activityId);
  if (!sessions.length) return [];
  const fullText = String(registration.fullParticipation || '');
  if (fullText === '是' || fullText.indexOf('全程') >= 0) return sessions;
  const selectedIds = parseSelectedSessionIds_(registration.selectedSessions);
  if (!selectedIds.length) return sessions.length === 1 ? sessions : [];
  return sessions.filter(function(session) {
    return selectedIds.indexOf(session.sessionId) >= 0;
  });
}

function buildExistingAttendanceKeys_(sheet) {
  const rows = buildRowsByHeader_(sheet);
  const keys = {};
  rows.forEach(function(row) {
    const key = row['報名資料Key'];
    if (key) keys[String(key)] = true;
  });
  return keys;
}

function generateAttendanceRowsForActivity_(activityId) {
  const ss = getActiveSpreadsheet_();
  const registrations = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS)).filter(function(row) {
    return String(row['活動ID'] || '') === String(activityId || '');
  });
  let added = 0;
  registrations.forEach(function(row) {
    added += ensureAttendanceRowsForRegistration_({
      activityId: row['活動ID'],
      name: row['學生姓名'] || row['學生或學員姓名'],
      unit: row['班級'] || row['學校或單位'],
      email: row['家長Email'] || row['Email'],
      fullParticipation: row['是否全程參加'],
      selectedSessions: row['選擇參加場次']
    });
  });
  return added;
}

function updateDashboardKpis_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  const activityRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES));
  const sessionRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS));
  const registrationRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS));
  const attendanceRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE));
  const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS));
  const taskOverviewRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW));
  const documentRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.DOCUMENTS));
  const checkedIn = attendanceRows.filter(function(row) { return row['簽到狀態'] === '已簽到'; }).length;
  const multiDay = activityRows.filter(function(row) {
    return row['是否多日活動'] === '是' || Number(row['活動天數'] || 0) > 1;
  }).length;
  const todayDueTasks = taskRows.filter(function(row) {
    return isSameDate_(row['建議完成日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
  }).length;
  const overdueTasks = taskRows.filter(function(row) {
    return calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
  }).length;
  const incompleteTasks = taskRows.filter(function(row) {
    return row['狀態'] && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
  }).length;
  const highRiskSessions = sessionRows.filter(function(row) {
    return row['風險等級'] === '高' || row['風險等級'] === '極高';
  }).length;
  const pendingDocuments = documentRows.filter(function(row) {
    return row['承辦人確認'] !== '已確認' && row['承辦人確認'] !== '不適用';
  }).length;
  const notebookCount = activityRows.filter(function(row) { return row['NotebookLM資料包連結']; }).length;
  const importantTasks = taskOverviewRows.filter(function(row) { return row['重要行事'] === '是'; });
  const syncedCalendarTasks = importantTasks.filter(function(row) {
    const status = String(row['日曆同步狀態'] || '');
    return status === '已同步' || status === '已更新' || status === '已重新建立';
  }).length;
  const calendarReadSuccess = importantTasks.filter(function(row) {
    return String(row['日曆回寫狀態'] || '').indexOf('已回寫') === 0;
  }).length;
  const calendarMissing = importantTasks.filter(function(row) {
    return String(row['日曆回寫狀態'] || '') === '日曆事件遺失';
  }).length;
  const calendarFailed = importantTasks.filter(function(row) {
    return String(row['日曆同步狀態'] || '').indexOf('同步失敗') === 0
      || String(row['日曆回寫狀態'] || '').indexOf('回寫失敗') === 0;
  }).length;
  const todayImportant = importantTasks.filter(function(row) {
    return isSameDate_(row['到期日'] || row['日曆日期'], new Date()) && row['狀態'] !== '已完成' && row['狀態'] !== '不適用';
  }).length;
  const delayedImportant = importantTasks.filter(function(row) {
    return row['狀態'] === '延後'
      || calculateDateStatus_(row['到期日'] || row['日曆日期'], row['狀態'] === '已完成') === '逾期';
  }).length;
  dashboard.getRange('B4:B23').setValues([
    [activityRows.length],
    [multiDay],
    [sessionRows.length],
    [registrationRows.length],
    [attendanceRows.length],
    [formatPercent_(checkedIn, attendanceRows.length)],
    [incompleteTasks],
    [todayDueTasks],
    [overdueTasks],
    [highRiskSessions],
    [pendingDocuments],
    [notebookCount],
    [nowText_()],
    [importantTasks.length],
    [syncedCalendarTasks],
    [calendarReadSuccess],
    [calendarMissing],
    [calendarFailed],
    [todayImportant],
    [delayedImportant]
  ]);
}

function updateDashboardStatusLights_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  ensureDashboardVisualLayout_(dashboard);

  const metricRows = dashboard.getRange('A4:B23').getValues();
  const attendanceCount = Number(dashboard.getRange('B8').getValue() || 0);
  const statusRows = [];
  const statusBackgrounds = [];

  metricRows.forEach(function(row) {
    const metric = String(row[0] || '');
    const value = row[1];
    const result = buildDashboardStatusLight_(metric, value, attendanceCount);
    statusRows.push([result.status, result.hint]);
    statusBackgrounds.push([result.color, result.color]);
  });

  dashboard.getRange('C4:D23').setValues(statusRows).setBackgrounds(statusBackgrounds);
  dashboard.getRange('B4:B23').setHorizontalAlignment('right');
  dashboard.getRange('C4:C23').setHorizontalAlignment('center');
  dashboard.getRange('D4:D23').setHorizontalAlignment('left').setWrap(true);
}

function buildDashboardStatusLight_(metric, value, attendanceCount) {
  const number = Number(value || 0);
  const percent = parseDashboardPercent_(value);

  if (metric === '活動總數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有活動資料')
      : dashboardLight_('gray', '⚪ 尚無資料', '請先新增活動');
  }
  if (metric === '多日活動數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有多日活動')
      : dashboardLight_('gray', '⚪ 尚無資料', '目前沒有多日活動');
  }
  if (metric === '場次總數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有場次設定')
      : dashboardLight_('yellow', '🟡 注意', '請產生或更新場次設定');
  }
  if (metric === '報名總人數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已有資料', '已有報名資料')
      : dashboardLight_('yellow', '🟡 注意', '尚未同步報名資料');
  }
  if (metric === '場次簽到總筆數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已有資料', '已有簽到資料')
      : dashboardLight_('yellow', '🟡 注意', '尚未產生或同步簽到資料');
  }
  if (metric === '平均出席率') {
    if (!attendanceCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚未建立簽到統計');
    if (percent >= 80) return dashboardLight_('green', '🟢 正常', '出席狀況良好');
    if (percent >= 50) return dashboardLight_('yellow', '🟡 注意', '出席率偏低,建議檢查簽到狀態');
    return dashboardLight_('red', '🔴 異常', '請更新場次簽到狀態');
  }
  if (metric === '未完成任務數') {
    if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有未完成任務');
    if (number <= 5) return dashboardLight_('yellow', '🟡 注意', '仍有少量任務待處理');
    return dashboardLight_('red', '🔴 待處理', '未完成任務偏多,請查看任務總覽');
  }
  if (metric === '今日到期任務') {
    return number === 0
      ? dashboardLight_('green', '🟢 正常', '今日沒有到期任務')
      : dashboardLight_('yellow', '🟡 今日到期', '今日有任務需要完成');
  }
  if (metric === '延宕任務數') {
    if (number === 0) return dashboardLight_('green', '🟢 正常', '沒有延宕任務');
    if (number <= 3) return dashboardLight_('yellow', '🟡 注意', '有少量延宕任務');
    return dashboardLight_('red', '🔴 逾期', '延宕任務偏多,請優先處理');
  }
  if (metric === '高風險場次數') {
    return number === 0
      ? dashboardLight_('green', '🟢 無風險', '目前沒有高風險場次')
      : dashboardLight_('red', '🔴 風險偏高', '請檢查場次人數、簽到率或資料缺漏');
  }
  if (metric === '待確認文件數') {
    if (number === 0) return dashboardLight_('green', '🟢 已完成', '目前沒有待確認文件');
    if (number <= 5) return dashboardLight_('yellow', '🟡 待確認', '仍有文件需要確認');
    return dashboardLight_('red', '🔴 待處理', '待確認文件偏多,請整理行政文件');
  }
  if (metric === 'NotebookLM 匯出數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已匯出', '已有 NotebookLM 資料包')
      : dashboardLight_('yellow', '🟡 尚未匯出', '活動結案前請匯出資料包');
  }
  if (metric === '最後更新時間') {
    return value
      ? dashboardLight_('green', '🟢 已更新', '中控台已完成更新')
      : dashboardLight_('gray', '⚪ 尚無資料', '尚未更新中控台');
  }
  if (metric === '重要行事總數') {
    return number > 0
      ? dashboardLight_('green', '🟢 已建立', '已有重要行事')
      : dashboardLight_('gray', '⚪ 尚無資料', '尚未建立重要行事');
  }
  if (metric === '已同步日曆數') {
    const importantCount = Number(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.DASHBOARD).getRange('B17').getValue() || 0);
    if (!importantCount) return dashboardLight_('gray', '⚪ 尚無資料', '尚無重要行事');
    return number >= importantCount
      ? dashboardLight_('green', '🟢 正常', '重要行事已同步')
      : dashboardLight_('yellow', '🟡 注意', '仍有重要行事未同步');
  }
  if (metric === '日曆回寫成功數') {
    return number > 0
      ? dashboardLight_('green', '🟢 正常', '已有日曆回寫紀錄')
      : dashboardLight_('gray', '⚪ 尚無資料', '尚未從日曆回寫');
  }
  if (metric === '日曆事件遺失數') {
    return number > 0
      ? dashboardLight_('red', '🔴 待處理', '有日曆事件遺失')
      : dashboardLight_('green', '🟢 正常', '沒有日曆事件遺失');
  }
  if (metric === '日曆同步失敗數') {
    return number > 0
      ? dashboardLight_('red', '🔴 待處理', '有日曆同步或回寫失敗')
      : dashboardLight_('green', '🟢 正常', '沒有日曆同步失敗');
  }
  if (metric === '今日到期重要行事') {
    return number > 0
      ? dashboardLight_('yellow', '🟡 今日到期', '今日有重要行事')
      : dashboardLight_('green', '🟢 正常', '今日沒有到期重要行事');
  }
  if (metric === '延宕重要行事') {
    return number > 0
      ? dashboardLight_('red', '🔴 逾期', '有延宕重要行事')
      : dashboardLight_('green', '🟢 正常', '沒有延宕重要行事');
  }
  return dashboardLight_('gray', '⚪ 不適用', '此指標尚未定義燈號');
}

function dashboardLight_(level, status, hint) {
  const colors = {
    green: '#e8f5e9',
    yellow: '#fff8e1',
    red: '#ffebee',
    gray: '#f5f5f5'
  };
  return {
    status: status,
    hint: hint,
    color: colors[level] || colors.gray
  };
}

function parseDashboardPercent_(value) {
  if (typeof value === 'number') {
    return value <= 1 ? value * 100 : value;
  }
  const text = String(value || '').trim();
  if (!text) return 0;
  const numeric = Number(text.replace('%', ''));
  return isNaN(numeric) ? 0 : numeric;
}

function updateDashboardSessionOverview_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  dashboard.getRange('A27:K36').clearContent();
  const activityNames = buildActivityNameMap_();
  const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).slice(0, 10);
  const rows = sessions.map(function(row) {
    return [
      row['活動ID'],
      activityNames[row['活動ID']] || '',
      row['場次ID'],
      row['場次日期'],
      row['場次名稱'],
      row['報名人數'],
      row['簽到人數'],
      row['出席率'],
      row['課前任務完成率'],
      row['目前狀態'],
      row['風險等級']
    ];
  });
  if (rows.length) dashboard.getRange(27, 1, rows.length, 11).setValues(rows);
}

function updateDashboardRedTasks_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  dashboard.getRange('A40:G51').clearContent();
  const tasks = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
    return row['狀態'] === '延後'
      || row['優先程度'] === '高' && row['狀態'] !== '已完成'
      || calculateDateStatus_(row['建議完成日期'], row['狀態'] === '已完成') === '逾期';
  }).slice(0, 12);
  const rows = tasks.map(function(row) {
    return [row['活動ID'], row['場次ID'], row['任務名稱'], row['負責人'], row['建議完成日期'], row['狀態'], row['卡關原因']];
  });
  if (rows.length) dashboard.getRange(40, 1, rows.length, 7).setValues(rows);
}

function updateDashboardTodaySessions_() {
  const ss = getActiveSpreadsheet_();
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  dashboard.getRange('A55:G66').clearContent();
  const activityNames = buildActivityNameMap_();
  const today = new Date();
  const tomorrow = addDays_(today, 1);
  const sessions = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS)).filter(function(row) {
    return isSameDate_(row['場次日期'], today) || isSameDate_(row['場次日期'], tomorrow);
  }).slice(0, 12);
  const rows = sessions.map(function(row) {
    return [
      row['場次日期'],
      activityNames[row['活動ID']] || '',
      row['場次ID'],
      row['場次名稱'],
      valueOrPending_(row['開始時間']) + '-' + valueOrPending_(row['結束時間']),
      row['地點或線上連結'],
      row['目前狀態']
    ];
  });
  if (rows.length) dashboard.getRange(55, 1, rows.length, 7).setValues(rows);
}

function buildActivityNameMap_() {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
  const map = {};
  rows.forEach(function(row) {
    if (row['活動ID']) map[row['活動ID']] = row['活動名稱'];
  });
  return map;
}

function buildSessionNameMap_() {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
  const map = {};
  rows.forEach(function(row) {
    const activityId = row['活動ID'];
    const sessionId = row['場次ID'];
    if (activityId && sessionId) {
      map[activityId + '::' + sessionId] = row['場次名稱'] || '';
    }
  });
  return map;
}

檔案 07

AttendanceFormService.gs|場次簽到表單與簽到狀態同步

/**
 * 營隊/活動管理中樞:場次簽到表單與簽到回應同步。
 */
function createAttendanceFormsForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  const result = createAttendanceFormsForActivityContext_(context);
  updateDashboard_();
  alert_([
    '場次簽到表單處理完成。',
    '新建立:' + result.created + ' 張',
    '已存在略過:' + result.skipped + ' 張',
    '',
    '簽到表單連結已回填到 02_場次設定。'
  ].join('\n'));
}

function createAttendanceFormsForActivityContext_(context) {
  let activity = context.activity;

  if (!activity.id) {
    throw new Error('此活動缺少活動ID,請先建立活動資料包。');
  }

  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  activity = context.activity;

  const attendanceFolder = packageContext.subfolders['01_報名表單與回覆'] || getCampRootFolder_();
  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) {
    sessions = generateSessionsForActivity_(activity);
  }
  if (!sessions.length) {
    throw new Error('此活動尚未建立場次,無法產生簽到表單。');
  }

  const ss = getActiveSpreadsheet_();
  const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  const sessionMap = ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
  let created = 0;
  let skipped = 0;

  sessions.forEach(function(session) {
    const row = findSessionRow_(sessionSheet, sessionMap, activity.id, session.sessionId);
    if (!row) return;

    const existingUrl = String(readCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結') || '').trim();
    if (existingUrl) {
      skipped++;
      return;
    }

    const result = createAttendanceFormForSession_(ss, activity, session, attendanceFolder);
    writeCellByHeader_(sessionSheet, row, sessionMap, '簽到表單連結', result.prefilledUrl);
    writeCellByHeader_(sessionSheet, row, sessionMap, '目前狀態', '準備中');

    appendDocumentListRow_(
      activity,
      '簽到表單',
      result.title,
      '本場研習或活動簽到使用',
      result.prefilledUrl,
      'Google Form',
      '待使用',
      '待確認',
      '編輯連結:' + result.editUrl + '\nraw 回應分頁:' + result.responseSheetName
    );
    created++;
  });

  return { created: created, skipped: skipped };
}

function createAttendanceFormForSession_(ss, activity, session, folder) {
  const title = [
    '簽到表單',
    activity.id,
    session.sessionId,
    sanitizeFileName_(activity.name)
  ].join('_');
  const beforeSheetIds = getSheetIds_(ss);
  const form = FormApp.create(title);

  form.setDescription([
    '活動名稱:' + activity.name,
    '場次:' + session.sessionId + '|' + valueOrPending_(session.title),
    '日期:' + formatDateValue_(session.date),
    '時間:' + valueOrPending_(session.startTime) + '-' + valueOrPending_(session.endTime),
    '地點或連結:' + valueOrPending_(session.location),
    '',
    '本表單已自動帶入活動ID、場次ID與場次名稱。',
    '請直接填寫姓名、單位與 Email 完成簽到。'
  ].join('\n'));
  form.setCollectEmail(false);
  form.setAllowResponseEdits(false);

  form.addTextItem()
    .setTitle('姓名')
    .setRequired(true);
  form.addTextItem()
    .setTitle('單位')
    .setRequired(true)
    .setHelpText('請填寫學校、服務單位或所屬機關。');
  form.addTextItem()
    .setTitle('Email')
    .setRequired(false)
    .setHelpText('選填。若需研習時數或後續通知,建議填寫。');
  form.addMultipleChoiceItem()
    .setTitle('是否本人簽到')
    .setChoiceValues(['本人簽到', '工作人員協助登記'])
    .setRequired(true);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);

  const activityIdItem = form.addTextItem()
    .setTitle('活動ID')
    .setRequired(true)
    .setHelpText('系統已自動帶入,請勿修改。');
  const sessionIdItem = form.addTextItem()
    .setTitle('場次ID')
    .setRequired(true)
    .setHelpText('系統已自動帶入,請勿修改。');
  const sessionNameItem = form.addTextItem()
    .setTitle('場次名稱')
    .setRequired(true)
    .setHelpText('系統已自動帶入,請勿修改。');

  form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
  Utilities.sleep(1200);

  const responseSheet = findNewSheetAfterFormDestination_(ss, beforeSheetIds);
  const responseSheetName = renameSheetSafely_(
    responseSheet,
    'raw_簽到回應_' + activity.id + '_' + session.sessionId
  );
  if (responseSheet) {
    try {
      responseSheet.hideSheet();
    } catch (error) {
      Logger.log('隱藏簽到 raw 分頁失敗:' + error.message);
    }
  }

  const prefilledUrl = form.createResponse()
    .withItemResponse(activityIdItem.createResponse(activity.id))
    .withItemResponse(sessionIdItem.createResponse(session.sessionId))
    .withItemResponse(sessionNameItem.createResponse(session.title || session.sessionId))
    .toPrefilledUrl();

  moveFileToFolder_(form.getId(), folder);
  return {
    form: form,
    title: title,
    prefilledUrl: prefilledUrl,
    editUrl: form.getEditUrl(),
    responseSheetName: responseSheetName
  };
}

function syncAttendanceFormResponses() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const attendanceSheet = getOrCreateSheet_(ss, CAMP_SHEETS.ATTENDANCE);
  const attendanceMap = ensureHeaders_(attendanceSheet, CAMP_ATTENDANCE_HEADERS);
  let updated = 0;
  let added = 0;
  let skipped = 0;

  ss.getSheets().forEach(function(sheet) {
    if (sheet.getName().indexOf('raw_簽到回應_') !== 0) return;
    if (sheet.getLastRow() < 2) return;

    buildRowsByHeader_(sheet).forEach(function(row) {
      const result = syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, row, sheet.getName());
      if (result === 'updated') updated++;
      else if (result === 'added') added++;
      else skipped++;
    });
  });

  updateSessionStats_();
  updateDashboard_();
  alert_([
    '簽到狀態更新完成。',
    '已更新既有名冊:' + updated + ' 筆',
    '新增未在報名名單者:' + added + ' 筆',
    '略過:' + skipped + ' 筆'
  ].join('\n'));
}

function syncSingleAttendanceResponse_(attendanceSheet, attendanceMap, response, sourceSheetName) {
  const activityId = String(response['活動ID'] || '').trim();
  const sessionId = String(response['場次ID'] || '').trim();
  const sessionName = String(response['場次名稱'] || '').trim();
  const name = String(response['姓名'] || '').trim();
  const unit = String(response['單位'] || '').trim();
  const email = String(response['Email'] || '').trim();
  const timestamp = response['時間戳記'] || response['Timestamp'] || new Date();
  const note = String(response['備註'] || '').trim();

  if (!activityId || !sessionId || !name) return 'skipped';

  const existingRow = findAttendanceRowForResponse_(
    attendanceSheet,
    attendanceMap,
    activityId,
    sessionId,
    name,
    unit,
    email
  );

  if (existingRow) {
    const currentStatus = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態') || '');
    if (currentStatus === '已簽到') return 'skipped';

    writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到狀態', '已簽到');
    writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '簽到時間', timestamp);

    const oldNote = String(readCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註') || '').trim();
    const newNote = [
      oldNote,
      note ? '簽到備註:' + note : '',
      '簽到來源:' + sourceSheetName
    ].filter(Boolean).join('\n');
    writeCellByHeader_(attendanceSheet, existingRow, attendanceMap, '備註', newNote);
    return 'updated';
  }

  attendanceSheet.appendRow([
    activityId,
    sessionId,
    sessionName,
    '',
    name,
    unit,
    email,
    buildRegistrationKey_({
      activityId: activityId,
      name: name,
      unit: unit,
      email: email
    }, sessionId),
    '否',
    '已簽到',
    timestamp,
    '未簽退',
    '',
    '',
    [
      '未在報名名單中,請人工確認。',
      note ? '簽到備註:' + note : '',
      '簽到來源:' + sourceSheetName
    ].filter(Boolean).join('\n')
  ]);
  return 'added';
}

function findAttendanceRowForResponse_(sheet, map, activityId, sessionId, name, unit, email) {
  let fallbackByName = 0;

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
    const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
    if (rowActivityId !== String(activityId) || rowSessionId !== String(sessionId)) continue;

    const rowName = String(readCellByHeader_(sheet, row, map, '學生或學員姓名') || '').trim();
    const rowUnit = String(readCellByHeader_(sheet, row, map, '學校或單位') || '').trim();
    const rowEmail = String(readCellByHeader_(sheet, row, map, 'Email') || '').trim();

    if (email && rowEmail && rowEmail === email) return row;
    if (rowName === name && rowUnit === unit) return row;
    if (!fallbackByName && rowName === name) fallbackByName = row;
  }

  return fallbackByName;
}

function findSessionRow_(sheet, headerMap, activityId, sessionId) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const rowActivityId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
    const rowSessionId = String(readCellByHeader_(sheet, row, headerMap, '場次ID') || '').trim();
    if (rowActivityId === String(activityId) && rowSessionId === String(sessionId)) return row;
  }
  return 0;
}

function getSheetIds_(ss) {
  const ids = {};
  ss.getSheets().forEach(function(sheet) {
    ids[sheet.getSheetId()] = true;
  });
  return ids;
}

function findNewSheetAfterFormDestination_(ss, beforeSheetIds) {
  const sheets = ss.getSheets();
  for (let i = 0; i < sheets.length; i++) {
    if (!beforeSheetIds[sheets[i].getSheetId()]) return sheets[i];
  }
  return null;
}

function renameSheetSafely_(sheet, desiredName) {
  if (!sheet) return '';
  const ss = sheet.getParent();
  let finalName = desiredName;
  let index = 2;

  while (ss.getSheetByName(finalName)) {
    finalName = desiredName + '_' + index;
    index++;
  }

  sheet.setName(finalName);
  return finalName;
}

檔案 08

TaskOverviewService.gs|任務總覽整理與視覺標記

/**
 * 營隊/活動管理中樞:任務總覽。
 *
 * 將 05_任務追蹤 的 WBS 明細整理成承辦人每日檢視用的 05_任務總覽。
 */
function refreshTaskOverview() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);

  const overviewSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
  const existingOverviewState = buildExistingTaskOverviewState_(overviewSheet);
  overviewSheet.clear();
  setupTaskOverviewSheet_(overviewSheet);

  const activityNameMap = buildActivityNameMap_();
  const sessionNameMap = buildSessionNameMap_();
  const taskSheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
  ensureHeaders_(taskSheet, CAMP_TASK_HEADERS);

  const overviewRows = buildRowsByHeader_(taskSheet)
    .filter(shouldIncludeTaskInOverview_)
    .map(function(row) {
      const activityId = row['活動ID'];
      const sessionId = row['場次ID'] || '';
      const taskId = buildTaskOverviewId_(row);
      const preserved = existingOverviewState[taskId] || {};
      const deadline = row['建議完成日期'] || '';
      const completion = row['實際完成日期'] || '';
      const task = {
        '任務ID': taskId,
        '活動ID': activityId,
        '活動名稱': activityNameMap[activityId] || '',
        '場次ID': sessionId,
        '場次名稱': sessionNameMap[activityId + '::' + sessionId] || '',
        '階段': row['階段'] || '',
        '任務名稱': row['任務名稱'] || '',
        '任務類型': row['階段'] || '',
        '負責人': row['負責人'] || '',
        '截止日': deadline,
        '到期日': deadline,
        '狀態': row['狀態'] || '未開始',
        '優先程度': row['優先程度'] || '中',
        '卡關原因/下一步': buildNextStepText_(row),
        '備註': row['備註'] || row['卡關原因'] || '',
        '重要行事': preserved['重要行事'] || (row['是否關鍵任務'] === '是' ? '是' : ''),
        '日曆標題': preserved['日曆標題'] || '',
        '日曆日期': preserved['日曆日期'] || deadline,
        '開始時間': preserved['開始時間'] || '',
        '結束時間': preserved['結束時間'] || '',
        '日曆事件ID': preserved['日曆事件ID'] || '',
        '日曆同步狀態': preserved['日曆同步狀態'] || '未同步',
        '最後同步時間': preserved['最後同步時間'] || '',
        '日曆回寫狀態': preserved['日曆回寫狀態'] || '未讀取',
        '日曆最後讀取時間': preserved['日曆最後讀取時間'] || '',
        '完成時間': completion,
        '完成日': completion,
        '最後更新時間': row['最後更新時間'] || ''
      };
      return CAMP_TASK_OVERVIEW_HEADERS.map(function(header) {
        return task[header] || '';
      });
    });

  if (overviewRows.length > 0) {
    overviewSheet.getRange(2, 1, overviewRows.length, CAMP_TASK_OVERVIEW_HEADERS.length)
      .setValues(overviewRows);
  }

  applyTaskOverviewFormatting_(overviewSheet);
  syncImportantTasksToCalendar_();
  moveTaskOverviewNearTaskSheet_();
  updateDashboard_();
  alert_('任務總覽已更新,共 ' + overviewRows.length + ' 筆。');
}

function buildExistingTaskOverviewState_(sheet) {
  if (!sheet || sheet.getLastRow() <= 1) return {};
  const map = buildHeaderMap_(sheet);
  const stateHeaders = [
    '重要行事',
    '日曆標題',
    '日曆日期',
    '開始時間',
    '結束時間',
    '日曆事件ID',
    '日曆同步狀態',
    '最後同步時間',
    '日曆回寫狀態',
    '日曆最後讀取時間'
  ];
  const state = {};
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const taskId = String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim();
    if (!taskId) continue;
    state[taskId] = {};
    stateHeaders.forEach(function(header) {
      state[taskId][header] = readCellByHeader_(sheet, row, map, header);
    });
  }
  return state;
}

function buildTaskOverviewId_(row) {
  const parts = [
    row['活動ID'],
    row['場次ID'] || 'ALL',
    row['WBS編碼'] || row['階段'] || '',
    row['任務名稱'] || ''
  ];
  return 'TASK-' + parts.join('-').replace(/[^\w\u4e00-\u9fa5-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}

function shouldIncludeTaskInOverview_(row) {
  const status = String(row['狀態'] || '');
  const priority = String(row['優先程度'] || '');
  const isKey = String(row['是否關鍵任務'] || '');
  const taskName = String(row['任務名稱'] || '');
  const phase = String(row['階段'] || '');

  if (status === '不適用') {
    return false;
  }

  if (status === '已完成') return true;
  if (priority === '高') return true;
  if (isKey === '是') return true;
  if (status === '進行中' || status === '待確認' || status === '延後') return true;

  const keywords = ['提醒', '簽到', '回饋', '成果', '講義', '連結', '通知', '名冊'];
  return keywords.some(function(keyword) {
    return taskName.indexOf(keyword) >= 0 || phase.indexOf(keyword) >= 0;
  });
}

function buildNextStepText_(row) {
  const stuck = String(row['卡關原因'] || '').trim();
  if (stuck) return stuck;

  const status = String(row['狀態'] || '');
  const taskName = String(row['任務名稱'] || '');
  const documentNeed = String(row['需要文件或表單'] || '');

  if (status === '待確認') {
    return '請承辦人或權責單位確認。';
  }

  if (status === '延後') {
    return '請確認延後原因與新的完成日期。';
  }

  if (documentNeed) {
    return '請確認或補齊:' + documentNeed;
  }

  if (taskName.indexOf('提醒') >= 0) {
    return '請確認提醒內容與發送對象。';
  }

  if (taskName.indexOf('簽到') >= 0) {
    return '請確認簽到方式、名單與時數需求。';
  }

  if (taskName.indexOf('成果') >= 0 || taskName.indexOf('回饋') >= 0) {
    return '請整理回饋、照片、講義或成果摘要。';
  }

  return '請依任務內容處理。';
}

function formatTaskOverviewHeader_(sheet) {
  sheet.getRange(1, 1, 1, CAMP_TASK_OVERVIEW_HEADERS.length)
    .setFontWeight('bold')
    .setBackground('#1e3a8a')
    .setFontColor('#ffffff')
    .setHorizontalAlignment('center')
    .setVerticalAlignment('middle')
    .setWrap(true);

  sheet.setFrozenRows(1);
  sheet.setFrozenColumns(5);
}

function applyTaskOverviewFormatting_(sheet) {
  const lastRow = sheet.getLastRow();
  const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
  const existingFilter = sheet.getFilter();
  if (existingFilter) existingFilter.remove();

  if (lastRow < 2) {
    sheet.autoResizeColumns(1, lastColumn);
    return;
  }

  sheet.getRange(2, 1, lastRow - 1, lastColumn)
    .setVerticalAlignment('middle')
    .setWrap(true);

  const statusCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('狀態') + 1;
  const priorityCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('優先程度') + 1;
  const deadlineCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('到期日') + 1;
  const calendarSyncCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆同步狀態') + 1;
  const calendarReadCol = CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆回寫狀態') + 1;

  for (let row = 2; row <= lastRow; row++) {
    const status = String(sheet.getRange(row, statusCol).getValue() || '');
    const priority = String(sheet.getRange(row, priorityCol).getValue() || '');
    const deadline = sheet.getRange(row, deadlineCol).getValue();
    const isDone = status === '已完成' || status === '不適用';
    const dateStatus = calculateDateStatus_(deadline, isDone);

    if (status === '已完成') {
      sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
    } else if (dateStatus === '逾期') {
      sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
    } else if (dateStatus === '即將到期') {
      sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
    } else {
      sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
    }

    if (CAMP_TASK_STATUS_COLORS[status]) {
      sheet.getRange(row, statusCol).setBackground(CAMP_TASK_STATUS_COLORS[status]);
    }

    if (CAMP_PRIORITY_COLORS[priority]) {
      sheet.getRange(row, priorityCol).setBackground(CAMP_PRIORITY_COLORS[priority]);
    }

    if (calendarSyncCol > 0) {
      sheet.getRange(row, calendarSyncCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarSyncCol).getValue()));
    }
    if (calendarReadCol > 0) {
      sheet.getRange(row, calendarReadCol).setBackground(getCalendarStatusColor_(sheet.getRange(row, calendarReadCol).getValue()));
    }
  }

  sheet.autoResizeColumns(1, lastColumn);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('活動名稱') + 1, 280);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('任務名稱') + 1, 220);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('卡關原因/下一步') + 1, 320);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('備註') + 1, 260);
  sheet.setColumnWidth(CAMP_TASK_OVERVIEW_HEADERS.indexOf('日曆標題') + 1, 280);
  sheet.getRange(1, 1, lastRow, lastColumn).createFilter();
}

function moveTaskOverviewNearTaskSheet_() {
  const ss = getActiveSpreadsheet_();
  const sheet = ss.getSheetByName(CAMP_SHEETS.TASK_OVERVIEW);
  if (!sheet) return;
  ss.setActiveSheet(sheet);
  ss.moveActiveSheet(CAMP_SHEET_ORDER.indexOf(CAMP_SHEETS.TASK_OVERVIEW) + 1);
}

function getCalendarStatusColor_(value) {
  const text = String(value || '');
  if (!text || text === '未讀取' || text === '尚無資料') return '#f5f5f5';
  if (text.indexOf('失敗') >= 0 || text.indexOf('遺失') >= 0) return '#ffebee';
  if (text.indexOf('需人工確認') >= 0 || text === '未同步' || text === '需重新同步') return '#fff8e1';
  if (text.indexOf('已同步') >= 0 || text.indexOf('已更新') >= 0 || text.indexOf('已回寫') >= 0 || text.indexOf('已重新建立') >= 0) return '#e8f5e9';
  return '#f5f5f5';
}

function handleTaskOverviewStatusEdit_(e) {
  if (!e || !e.range) return;
  const sheet = e.range.getSheet();
  if (sheet.getName() !== CAMP_SHEETS.TASK_OVERVIEW) return;
  if (e.range.getRow() <= 1 || e.range.getNumRows() !== 1 || e.range.getNumColumns() !== 1) return;

  const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  const statusCol = map['狀態'];
  if (!statusCol || e.range.getColumn() !== statusCol) return;

  const row = e.range.getRow();
  const newStatus = String(e.value || e.range.getValue() || '').trim();
  const oldStatus = String(e.oldValue || '').trim();
  const completionCol = map['完成時間'];
  const completionDayCol = map['完成日'];
  const updatedCol = map['最後更新時間'];
  const timestamp = nowMinuteText_();

  if (newStatus === '已完成') {
    const currentCompletion = completionCol ? String(sheet.getRange(row, completionCol).getValue() || '').trim() : '';
    if (completionCol && oldStatus !== '已完成' && !currentCompletion) {
      sheet.getRange(row, completionCol).setValue(timestamp);
    }
    const currentCompletionDay = completionDayCol ? String(sheet.getRange(row, completionDayCol).getValue() || '').trim() : '';
    if (completionDayCol && oldStatus !== '已完成' && !currentCompletionDay) {
      sheet.getRange(row, completionDayCol).setValue(timestamp);
    }
  }

  if (updatedCol) {
    sheet.getRange(row, updatedCol).setValue(timestamp);
  }

  applyTaskOverviewRowFormatting_(sheet, row);
  const task = readTaskOverviewRow_(sheet, row, map);
  syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp);
  if (task.activityId) {
    refreshTaskProgress_(task.activityId, task.sessionId);
  }
  updateDashboard_();
}

function readTaskOverviewRow_(sheet, row, map) {
  return {
    activityId: String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim(),
    activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || '').trim(),
    sessionId: String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim(),
    phase: String(readCellByHeader_(sheet, row, map, '階段') || '').trim(),
    taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
    owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
    deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
    status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
    completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
    lastUpdated: readCellByHeader_(sheet, row, map, '最後更新時間')
  };
}

function applyTaskOverviewRowFormatting_(sheet, row) {
  const map = buildHeaderMap_(sheet);
  const lastColumn = CAMP_TASK_OVERVIEW_HEADERS.length;
  const status = String(readCellByHeader_(sheet, row, map, '狀態') || '');
  const priority = String(readCellByHeader_(sheet, row, map, '優先程度') || '');
  const deadline = readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日');
  const dateStatus = calculateDateStatus_(deadline, status === '已完成' || status === '不適用');

  if (status === '已完成') {
    sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_TASK_STATUS_COLORS['已完成']);
  } else if (dateStatus === '逾期') {
    sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_OVERDUE_ROW_COLOR);
  } else if (dateStatus === '即將到期') {
    sheet.getRange(row, 1, 1, lastColumn).setBackground(CAMP_SOON_ROW_COLOR);
  } else {
    sheet.getRange(row, 1, 1, lastColumn).setBackground('#ffffff');
  }

  if (CAMP_TASK_STATUS_COLORS[status] && map['狀態']) {
    sheet.getRange(row, map['狀態']).setBackground(CAMP_TASK_STATUS_COLORS[status]);
  }
  if (CAMP_PRIORITY_COLORS[priority] && map['優先程度']) {
    sheet.getRange(row, map['優先程度']).setBackground(CAMP_PRIORITY_COLORS[priority]);
  }
  if (map['日曆同步狀態']) {
    sheet.getRange(row, map['日曆同步狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆同步狀態')));
  }
  if (map['日曆回寫狀態']) {
    sheet.getRange(row, map['日曆回寫狀態']).setBackground(getCalendarStatusColor_(readCellByHeader_(sheet, row, map, '日曆回寫狀態')));
  }
}

function syncTaskOverviewRowToTaskSheet_(task, newStatus, timestamp) {
  if (!task.activityId || !task.taskName) return;
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
  const map = ensureHeaders_(sheet, CAMP_TASK_HEADERS);
  const row = findTaskRowForOverview_(sheet, map, task);
  if (!row) return;

  writeCellByHeader_(sheet, row, map, '狀態', newStatus);
  writeCellByHeader_(sheet, row, map, '最後更新時間', timestamp);

  if (newStatus === '已完成') {
    const currentDoneAt = String(readCellByHeader_(sheet, row, map, '實際完成日期') || '').trim();
    if (!currentDoneAt) {
      writeCellByHeader_(sheet, row, map, '實際完成日期', task.completionTime || timestamp);
    }
  }
}

function findTaskRowForOverview_(sheet, map, task) {
  let bestRow = 0;
  let bestScore = -1;
  const targetDeadline = normalizeTaskDateForCompare_(task.deadline);

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
    const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
    const phase = String(readCellByHeader_(sheet, row, map, '階段') || '').trim();
    const taskName = String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim();
    if (activityId !== task.activityId || sessionId !== task.sessionId || phase !== task.phase || taskName !== task.taskName) {
      continue;
    }

    let score = 4;
    const owner = String(readCellByHeader_(sheet, row, map, '負責人') || '').trim();
    const deadline = normalizeTaskDateForCompare_(readCellByHeader_(sheet, row, map, '建議完成日期'));
    if (task.owner && owner === task.owner) score++;
    if (targetDeadline && deadline === targetDeadline) score++;

    if (score > bestScore) {
      bestScore = score;
      bestRow = row;
    }
  }
  return bestRow;
}

function normalizeTaskDateForCompare_(value) {
  const date = parseDate_(value);
  return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'yyyy/MM/dd') : String(value || '').trim();
}

function refreshTaskProgress_(activityId, sessionId) {
  if (!activityId) return;
  const ss = getActiveSpreadsheet_();
  const taskRows = buildRowsByHeader_(getOrCreateSheet_(ss, CAMP_SHEETS.TASKS)).filter(function(row) {
    return String(row['活動ID'] || '') === String(activityId || '')
      && String(row['任務名稱'] || '').trim()
      && String(row['狀態'] || '') !== '不適用';
  });

  const total = taskRows.length;
  const done = taskRows.filter(function(row) {
    return String(row['狀態'] || '') === '已完成';
  }).length;
  const activityRate = formatPercent_(done, total);
  refreshActivityTaskProgress_(activityId, activityRate);

  if (sessionId && sessionId !== 'ALL' && sessionId !== 'POST') {
    const sessionRows = taskRows.filter(function(row) {
      return String(row['場次ID'] || '') === String(sessionId || '');
    });
    refreshSessionTaskProgress_(activityId, sessionId, sessionRows);
  }
}

function refreshActivityTaskProgress_(activityId, activityRate) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (String(readCellByHeader_(sheet, row, map, '活動ID') || '') !== String(activityId || '')) continue;
    writeCellByHeader_(sheet, row, map, '任務完成率', activityRate);
    writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
    return;
  }
}

function refreshSessionTaskProgress_(activityId, sessionId, sessionTaskRows) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  const map = ensureHeaders_(sheet, CAMP_SESSION_HEADERS);
  const total = sessionTaskRows.length;
  const done = sessionTaskRows.filter(function(row) {
    return String(row['狀態'] || '') === '已完成';
  }).length;
  const rate = formatPercent_(done, total);
  const status = total === 0 ? '無任務' : (done === total ? '已完成' : (done === 0 ? '未開始' : '進行中'));

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const rowActivityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
    const rowSessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '');
    if (rowActivityId !== String(activityId || '') || rowSessionId !== String(sessionId || '')) continue;
    writeCellByHeader_(sheet, row, map, '任務完成率', rate);
    writeCellByHeader_(sheet, row, map, '任務完成狀態', status);
    return;
  }
}

檔案 09

CalendarTaskService.gs|關鍵任務登記 Google Calendar

/**
 * 營隊/活動管理中樞:關鍵任務與 Google Calendar 同步。
 */
function syncImportantTasksToCalendar() {
  const result = syncImportantTasksToCalendar_();
  updateDashboard_();
  alert_('重要行事同步完成。建立:' + result.created + ',更新:' + result.updated + ',略過:' + result.skipped + ',失敗:' + result.failed + '。');
}

function syncCalendarBackToTaskOverview() {
  const result = syncTaskStatusFromCalendar_();
  updateDashboard_();
  alert_('已從 Google Calendar 同步任務狀態。回寫:' + result.updated + ',遺失:' + result.missing + ',失敗:' + result.failed + '。');
}

function createCalendarSyncTrigger() {
  createCalendarSyncTrigger_();
  alert_('已建立日曆同步觸發器:每 30 分鐘從 Google Calendar 回寫任務狀態。');
}

function removeCalendarSyncTrigger() {
  const removed = removeCalendarSyncTrigger_();
  alert_('已移除日曆同步觸發器:' + removed + ' 個。');
}

function syncImportantTasksToCalendar_() {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
  const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  const calendar = getTaskCalendar_();
  const result = { created: 0, updated: 0, skipped: 0, failed: 0 };

  if (!calendar) {
    writeCalendarSyncStatusForAll_(sheet, map, '同步失敗:找不到日曆');
    result.failed = Math.max(0, sheet.getLastRow() - 1);
    return result;
  }

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const task = readCalendarTaskFromRow_(sheet, row, map);
    if (!task.taskName) continue;

    const important = isImportantCalendarTask_(task);
    writeCellByHeader_(sheet, row, map, '重要行事', important ? '是' : '否');
    if (!important) {
      result.skipped++;
      continue;
    }

    const date = getTaskCalendarDate_(task);
    if (!date) {
      writeCellByHeader_(sheet, row, map, '日曆同步狀態', '同步失敗:缺少日期');
      result.failed++;
      continue;
    }

    try {
      const title = buildCalendarTitle_(task);
      const description = buildCalendarDescription_(task);
      const eventInfo = upsertTaskCalendarEvent_(calendar, task, title, description, date);
      writeCellByHeader_(sheet, row, map, '日曆標題', title);
      writeCellByHeader_(sheet, row, map, '日曆日期', date);
      writeCellByHeader_(sheet, row, map, '日曆事件ID', eventInfo.event.getId());
      writeCellByHeader_(sheet, row, map, '日曆同步狀態', eventInfo.status);
      writeCellByHeader_(sheet, row, map, '最後同步時間', nowText_());
      if (eventInfo.status === '已同步' || eventInfo.status === '已重新建立') result.created++;
      if (eventInfo.status === '已更新') result.updated++;
    } catch (error) {
      writeCellByHeader_(sheet, row, map, '日曆同步狀態', classifyCalendarError_(error, '同步失敗'));
      result.failed++;
    }
  }

  applyTaskOverviewFormatting_(sheet);
  return result;
}

function syncTaskStatusFromCalendar_() {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASK_OVERVIEW);
  const map = ensureHeaders_(sheet, CAMP_TASK_OVERVIEW_HEADERS);
  const calendar = getTaskCalendar_();
  const result = { updated: 0, missing: 0, failed: 0 };

  if (!calendar) {
    writeCalendarReadStatusForAll_(sheet, map, '回寫失敗:其他錯誤');
    result.failed = Math.max(0, sheet.getLastRow() - 1);
    return result;
  }

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const task = readCalendarTaskFromRow_(sheet, row, map);
    if (!task.eventId) continue;

    try {
      const event = calendar.getEventById(task.eventId);
      if (!event) {
        writeCellByHeader_(sheet, row, map, '日曆回寫狀態', '日曆事件遺失');
        writeCellByHeader_(sheet, row, map, '日曆同步狀態', '需重新同步');
        writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
        result.missing++;
        continue;
      }

      const changes = [];
      const parsedStatus = parseTaskStatusFromCalendarEvent_(event);
      if (parsedStatus && parsedStatus !== task.status) {
        writeCellByHeader_(sheet, row, map, '狀態', parsedStatus);
        changes.push('狀態更新');
        if (parsedStatus === '已完成' && task.status !== '已完成') {
          if (!task.completionTime) writeCellByHeader_(sheet, row, map, '完成時間', nowMinuteText_());
          if (!task.completionDay) writeCellByHeader_(sheet, row, map, '完成日', nowMinuteText_());
        }
      }

      const eventStart = event.getStartTime();
      const eventEnd = event.getEndTime();
      if (eventStart) {
        writeCellByHeader_(sheet, row, map, '日曆日期', eventStart);
        writeCellByHeader_(sheet, row, map, '到期日', eventStart);
        changes.push('日期更新');
      }
      if (!event.isAllDayEvent()) {
        writeCellByHeader_(sheet, row, map, '開始時間', formatTaskTime_(eventStart));
        writeCellByHeader_(sheet, row, map, '結束時間', formatTaskTime_(eventEnd));
        changes.push('時間更新');
      }

      const note = parseCalendarField_(event.getDescription(), '任務備註');
      if (note) {
        writeCellByHeader_(sheet, row, map, '備註', note);
        changes.push('備註更新');
      }

      writeCellByHeader_(sheet, row, map, '日曆標題', event.getTitle());
      writeCellByHeader_(sheet, row, map, '日曆最後讀取時間', nowText_());
      writeCellByHeader_(sheet, row, map, '日曆回寫狀態', changes.length ? '已回寫:' + changes.join('、') : '已回寫');
      applyTaskOverviewRowFormatting_(sheet, row);
      result.updated++;
    } catch (error) {
      writeCellByHeader_(sheet, row, map, '日曆回寫狀態', classifyCalendarError_(error, '回寫失敗'));
      result.failed++;
    }
  }

  return result;
}

function isImportantCalendarTask_(task) {
  if (!getTaskCalendarDate_(task)) return false;
  const text = [task.type, task.taskName, task.note].join(' ');
  const exclude = ['同步報名資料與統計', '更新場次統計', '更新任務總覽', '更新期限狀態', '一鍵更新場次簽到狀態', '產生 50 筆假資料', '系統初始化', '建立示範資料'];
  if (exclude.some(function(keyword) { return text.indexOf(keyword) >= 0; })) return false;
  const include = ['活動日期', '活動開始', '活動結束', '正式活動', '營隊日期', '研習日期', '報名開始', '報名截止', '錄取公告', '候補通知', '場次日期', '梯次日期', '課程日期', '研習場次', '活動場次', '行前通知', '寄送通知', '提醒信', '錄取通知', '簽到表單建立', '簽到準備', '簽到 QR Code', '簽到測試', '活動計畫', '行政文件', '成果報告', '成果檢查', '結案', '核銷', 'NotebookLM 匯出', '提醒', '通知', '簽到', '成果', '報名'];
  return task.isKey === '是' || include.some(function(keyword) { return text.indexOf(keyword) >= 0; });
}

function buildCalendarTitle_(task) {
  const text = [task.type, task.taskName].join(' ');
  let prefix = '【任務】';
  if (/活動|營隊|研習場次|正式/.test(text)) prefix = '【活動】';
  if (/報名截止|報名開始|錄取|候補/.test(text)) prefix = '【報名截止】';
  if (/通知|提醒信|行前/.test(text)) prefix = '【通知】';
  if (/簽到/.test(text)) prefix = '【簽到準備】';
  if (/文件|計畫|成果報告/.test(text)) prefix = '【文件】';
  if (/結案|核銷|NotebookLM/.test(text)) prefix = '【結案】';
  return prefix + valueOrPending_(task.activityName) + (task.sessionName ? '|' + task.sessionName : '|' + task.taskName);
}

function buildCalendarDescription_(task) {
  return [
    '此事件由營隊活動管理中樞自動建立。',
    '',
    '【系統資料】',
    '任務ID:' + task.taskId,
    '活動ID:' + task.activityId,
    task.sessionId ? '場次ID:' + task.sessionId : '',
    '',
    '【任務資料】',
    task.activityName ? '活動名稱:' + task.activityName : '',
    task.sessionName ? '場次名稱:' + task.sessionName : '',
    '任務名稱:' + task.taskName,
    task.type ? '任務類型:' + task.type : '',
    task.owner ? '負責人:' + task.owner : '',
    '任務狀態:' + valueOrPending_(task.status),
    task.deadline ? '到期日:' + formatDateValue_(task.deadline) : '',
    '',
    '【可由日曆修改】',
    '任務狀態:' + valueOrPending_(task.status),
    '任務備註:' + (task.note || ''),
    '',
    '【相關連結】',
    task.folderUrl ? '資料夾連結:' + task.folderUrl : '',
    task.registrationUrl ? '報名表連結:' + task.registrationUrl : '',
    task.attendanceUrl ? '簽到表連結:' + task.attendanceUrl : ''
  ].filter(function(line) { return line !== ''; }).join('\n');
}

function getTaskCalendarDate_(task) {
  return parseDate_(task.calendarDate) || parseDate_(task.deadline) || parseDate_(task.fallbackDate);
}

function parseTaskStatusFromCalendarEvent_(event) {
  const description = event.getDescription() || '';
  const fromDescription = parseCalendarField_(description, '任務狀態');
  return normalizeCalendarTaskStatus_(fromDescription) || parseCalendarStatusFromTitle_(event.getTitle());
}

function createCalendarSyncTrigger_() {
  removeCalendarSyncTrigger_();
  ScriptApp.newTrigger('syncCalendarBackToTaskOverview')
    .timeBased()
    .everyMinutes(30)
    .create();
}

function removeCalendarSyncTrigger_() {
  let removed = 0;
  ScriptApp.getProjectTriggers().forEach(function(trigger) {
    if (trigger.getHandlerFunction() === 'syncCalendarBackToTaskOverview') {
      ScriptApp.deleteTrigger(trigger);
      removed++;
    }
  });
  return removed;
}

function getTaskCalendar_() {
  try {
    if (CAMP_CONFIG.CALENDAR_ID) {
      return CalendarApp.getCalendarById(CAMP_CONFIG.CALENDAR_ID);
    }
    return CalendarApp.getDefaultCalendar();
  } catch (error) {
    return null;
  }
}

function upsertTaskCalendarEvent_(calendar, task, title, description, date) {
  let event = task.eventId ? calendar.getEventById(task.eventId) : null;
  let status = '已更新';
  if (!event) {
    event = createTaskCalendarEvent_(calendar, task, title, description, date);
    return { event: event, status: task.eventId ? '已重新建立' : '已同步' };
  }
  event.setTitle(title);
  event.setDescription(description);
  applyTaskCalendarEventTime_(event, task, date);
  return { event: event, status: status };
}

function createTaskCalendarEvent_(calendar, task, title, description, date) {
  const start = buildTaskDateTime_(date, task.startTime);
  const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
  if (task.startTime) {
    return calendar.createEvent(title, start, end, { description: description });
  }
  return calendar.createAllDayEvent(title, date, { description: description });
}

function applyTaskCalendarEventTime_(event, task, date) {
  if (task.startTime) {
    const start = buildTaskDateTime_(date, task.startTime);
    const end = task.endTime ? buildTaskDateTime_(date, task.endTime) : addHours_(start, 1);
    event.setTime(start, end);
  } else {
    event.setAllDayDate(date);
  }
}

function readCalendarTaskFromRow_(sheet, row, map) {
  const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '').trim();
  const activity = getActivityCalendarContext_(activityId);
  const sessionId = String(readCellByHeader_(sheet, row, map, '場次ID') || '').trim();
  const session = getSessionCalendarContext_(activityId, sessionId);
  return {
    row: row,
    taskId: String(readCellByHeader_(sheet, row, map, '任務ID') || '').trim(),
    activityId: activityId,
    activityName: String(readCellByHeader_(sheet, row, map, '活動名稱') || activity.name || '').trim(),
    sessionId: sessionId,
    sessionName: String(readCellByHeader_(sheet, row, map, '場次名稱') || session.name || '').trim(),
    type: String(readCellByHeader_(sheet, row, map, '任務類型') || readCellByHeader_(sheet, row, map, '階段') || '').trim(),
    taskName: String(readCellByHeader_(sheet, row, map, '任務名稱') || '').trim(),
    owner: String(readCellByHeader_(sheet, row, map, '負責人') || '').trim(),
    status: String(readCellByHeader_(sheet, row, map, '狀態') || '').trim(),
    deadline: readCellByHeader_(sheet, row, map, '到期日') || readCellByHeader_(sheet, row, map, '截止日'),
    completionTime: readCellByHeader_(sheet, row, map, '完成時間'),
    completionDay: readCellByHeader_(sheet, row, map, '完成日'),
    note: String(readCellByHeader_(sheet, row, map, '備註') || readCellByHeader_(sheet, row, map, '卡關原因/下一步') || '').trim(),
    isKey: String(readCellByHeader_(sheet, row, map, '重要行事') || '').trim(),
    calendarTitle: String(readCellByHeader_(sheet, row, map, '日曆標題') || '').trim(),
    calendarDate: readCellByHeader_(sheet, row, map, '日曆日期'),
    startTime: String(readCellByHeader_(sheet, row, map, '開始時間') || '').trim(),
    endTime: String(readCellByHeader_(sheet, row, map, '結束時間') || '').trim(),
    eventId: String(readCellByHeader_(sheet, row, map, '日曆事件ID') || '').trim(),
    fallbackDate: session.date || activity.date,
    folderUrl: activity.folderUrl,
    registrationUrl: activity.registrationUrl,
    attendanceUrl: session.attendanceUrl
  };
}

function getActivityCalendarContext_(activityId) {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.ACTIVITIES));
  const row = rows.filter(function(item) { return String(item['活動ID'] || '') === String(activityId || ''); })[0] || {};
  return {
    name: row['活動名稱'] || '',
    date: row['活動日期'] || row['活動開始日'],
    folderUrl: row['專案資料夾連結'] || '',
    registrationUrl: row['報名表單連結'] || ''
  };
}

function getSessionCalendarContext_(activityId, sessionId) {
  const rows = buildRowsByHeader_(getOrCreateSheet_(getActiveSpreadsheet_(), CAMP_SHEETS.SESSIONS));
  const row = rows.filter(function(item) {
    return String(item['活動ID'] || '') === String(activityId || '') && String(item['場次ID'] || '') === String(sessionId || '');
  })[0] || {};
  return {
    name: row['場次名稱'] || '',
    date: row['場次日期'],
    attendanceUrl: row['簽到表單連結'] || ''
  };
}

function buildTaskDateTime_(date, timeText) {
  const base = parseDate_(date);
  const parts = String(timeText || '09:00').split(':');
  return new Date(base.getFullYear(), base.getMonth(), base.getDate(), Number(parts[0] || 9), Number(parts[1] || 0), 0);
}

function addHours_(date, hours) {
  return new Date(date.getTime() + Number(hours || 1) * 60 * 60 * 1000);
}

function formatTaskTime_(date) {
  return date ? Utilities.formatDate(date, CAMP_CONFIG.TIMEZONE, 'HH:mm') : '';
}

function parseCalendarField_(description, label) {
  const lines = String(description || '').split(/\r?\n/);
  for (let i = lines.length - 1; i >= 0; i--) {
    const line = lines[i];
    if (line.indexOf(label + ':') === 0) return line.replace(label + ':', '').trim();
  }
  return '';
}

function parseCalendarStatusFromTitle_(title) {
  const match = String(title || '').match(/^\[(完成|已完成|進行中|待確認|延宕|延後|取消|不適用)\]/);
  return match ? normalizeCalendarTaskStatus_(match[1]) : '';
}

function normalizeCalendarTaskStatus_(value) {
  const text = String(value || '').trim().toLowerCase();
  if (!text) return '';
  if (text === '完成' || text === '已完成' || text === 'done' || text === 'ok') return '已完成';
  if (text === '進行中') return '進行中';
  if (text === '待確認') return '待確認';
  if (text === '延宕' || text === '延後' || text === 'delay' || text === 'delayed') return '延後';
  if (text === '取消' || text === 'cancel') return '取消';
  if (text === '不適用') return '不適用';
  if (CAMP_TASK_STATUS_VALUES.indexOf(value) >= 0) return value;
  return '';
}

function classifyCalendarError_(error, prefix) {
  const message = String(error && error.message || '');
  if (message.indexOf('Authorization') >= 0 || message.indexOf('permission') >= 0 || message.indexOf('權限') >= 0) {
    return prefix + ':權限不足';
  }
  return prefix + ':其他錯誤';
}

function writeCalendarSyncStatusForAll_(sheet, map, status) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    writeCellByHeader_(sheet, row, map, '日曆同步狀態', status);
  }
}

function writeCalendarReadStatusForAll_(sheet, map, status) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    writeCellByHeader_(sheet, row, map, '日曆回寫狀態', status);
  }
}

檔案 10

ActivityIntakeForm.gs|活動新增表單與活動清單同步

/**
 * 營隊/活動管理中樞:活動新增表單。
 *
 * Google Form 的回應會寫入同一份 Spreadsheet,再由同步函式整理到
 * 「01_活動清單」。這樣承辦人可以用表單新增活動,後續仍以活動清單
 * 作為單一管理入口。
 */
function createActivityIntakeForm() {
  const result = createActivityIntakeForm_();
  alert_('活動新增表單已建立或確認存在:\n' + result.formUrl + '\n\n表單回應後,請執行「使用表單新增活動時才按:匯入活動回應」。若已使用新北示範資料,則不需要再匯入活動表單回應。');
  return result.formUrl;
}

function createActivityIntakeForm_() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const props = PropertiesService.getScriptProperties();
  const existingFormId = props.getProperty('CAMP_ACTIVITY_INTAKE_FORM_ID');
  if (existingFormId) {
    try {
      const form = FormApp.openById(existingFormId);
      ensureActivityIntakeFormItems_(form);
      form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
      writeActivityIntakeFormLinks_(ss, form);
      ensureActivityIntakeSubmitTrigger_(ss);
      return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
    } catch (error) {
      Logger.log('既有活動新增表單無法開啟,將重新建立:' + error.message);
    }
  }

  const beforeSheetIds = ss.getSheets().map(function(sheet) {
    return sheet.getSheetId();
  });

  const form = FormApp.create('營隊/活動新增表單');
  form.setDescription([
    '請填寫一筆營隊或活動資料。',
    '表單回應會寫入本中控 Spreadsheet,並可同步到「01_活動清單」。',
    '',
    CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
  ].join('\n'));
  form.addTextItem().setTitle('活動名稱').setRequired(true);
  form.addListItem().setTitle('活動類型').setChoiceValues(['營隊', '校外教學', '校內活動', '研習', '競賽', '成果發表', '其他']).setRequired(true);
  form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
  form.addDateItem().setTitle('活動日期').setHelpText('若是單日活動可填此欄;若是多日活動,請改填活動開始日與活動結束日。').setRequired(false);
  form.addDateItem().setTitle('活動開始日').setRequired(false);
  form.addDateItem().setTitle('活動結束日').setRequired(false);
  form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
  form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
  form.addDateItem().setTitle('報名截止日').setRequired(false);
  form.addDateItem().setTitle('同意書回收日').setRequired(false);
  form.addTextItem().setTitle('活動地點').setRequired(false);
  form.addTextItem().setTitle('參加對象').setRequired(false);
  form.addTextItem().setTitle('預估人數').setRequired(false);
  form.addTextItem().setTitle('承辦處室').setRequired(false);
  form.addTextItem().setTitle('承辦人').setRequired(false);
  form.addTextItem().setTitle('承辦人Email').setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否收費').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addParagraphTextItem().setTitle('費用說明').setRequired(false);
  form.addListItem().setTitle('交通方式').setChoiceValues(['校內活動無交通', '步行', '遊覽車', '大眾運輸', '家長接送', '其他或待確認']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要保險').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addMultipleChoiceItem().setTitle('是否需要家長同意書').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);
  form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
  Utilities.sleep(1000);

  const responseSheet = resolveActivityIntakeResponseSheet_(ss, beforeSheetIds);
  props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_ID', form.getId());
  props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_URL', form.getPublishedUrl());
  props.setProperty('CAMP_ACTIVITY_INTAKE_FORM_EDIT_URL', form.getEditUrl());
  props.setProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME', responseSheet.getName());

  moveFileToFolder_(form.getId(), getCampRootFolder_());
  writeActivityIntakeFormLinks_(ss, form);
  ensureActivityIntakeSubmitTrigger_(ss);
  return { formUrl: form.getPublishedUrl(), editUrl: form.getEditUrl() };
}

function syncActivityIntakeResponsesToActivityList() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const responseSheet = getActivityIntakeResponseSheet_(ss);
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
  const responseMap = ensureActivityIntakeSyncColumns_(responseSheet);
  const existingKeys = buildExistingActivityIntakeKeys_(activitySheet, activityMap);
  let added = 0;
  let skipped = 0;

  for (let row = 2; row <= responseSheet.getLastRow(); row++) {
    const currentStatus = String(readCellByHeader_(responseSheet, row, responseMap, '同步狀態') || '').trim();
    if (currentStatus && currentStatus !== '同步失敗') {
      skipped++;
      continue;
    }

    const timestamp = readCellByHeader_(responseSheet, row, responseMap, '時間戳記') || readCellByHeader_(responseSheet, row, responseMap, 'Timestamp');
    const name = String(readCellByHeader_(responseSheet, row, responseMap, '活動名稱') || '').trim();
    if (!name) {
      writeActivityIntakeSyncNote_(responseSheet, row, responseMap, '已略過', '缺少活動名稱,未同步。');
      skipped++;
      continue;
    }
    const key = buildActivityIntakeKey_(name, timestamp);
    if (existingKeys[key]) {
      writeActivityIntakeSyncNote_(
        responseSheet,
        row,
        responseMap,
        '已略過:同一表單回應已同步',
        '活動名稱與表單時間戳記已存在於 01_活動清單備註欄。'
      );
      skipped++;
      continue;
    }

    const existingActivity = findExistingActivityByName_(activitySheet, activityMap, name);
    if (existingActivity) {
      writeActivityIntakeSyncNote_(
        responseSheet,
        row,
        responseMap,
        '已略過:同名活動已存在',
        '已存在活動ID:' + existingActivity.id + ';活動名稱:' + existingActivity.name + '。未新增第二筆。'
      );
      appendNoteToExistingActivity_(activitySheet, activityMap, existingActivity.row, [
        '活動新增表單有同名資料,系統已略過避免重複。',
        '表單時間戳記:' + formatDateIfNeeded_(timestamp),
        '略過時間:' + nowText_()
      ].join('\n'));
      skipped++;
      continue;
    }

    const activityId = generateActivityId_();
    const startDate = readCellByHeader_(responseSheet, row, responseMap, '活動開始日') || readCellByHeader_(responseSheet, row, responseMap, '活動日期');
    const endDate = readCellByHeader_(responseSheet, row, responseMap, '活動結束日') || startDate;
    const activityDraft = {
      id: activityId,
      activityDate: readCellByHeader_(responseSheet, row, responseMap, '活動日期'),
      startDate: startDate,
      endDate: endDate
    };
    const activityDays = calculateActivityDays_(activityDraft);
    const object = {};
    object['活動ID'] = activityId;
    object['活動名稱'] = name;
    object['活動類型'] = readCellByHeader_(responseSheet, row, responseMap, '活動類型');
    object['報名表模板類型'] = readCellByHeader_(responseSheet, row, responseMap, '報名表模板類型') || '自動判斷';
    object['活動日期'] = readCellByHeader_(responseSheet, row, responseMap, '活動日期');
    object['活動開始日'] = startDate;
    object['活動結束日'] = endDate;
    object['每日預設開始時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設開始時間') || '09:00';
    object['每日預設結束時間'] = readCellByHeader_(responseSheet, row, responseMap, '每日預設結束時間') || '12:00';
    object['活動天數'] = activityDays;
    object['場次數'] = 0;
    object['是否多日活動'] = readCellByHeader_(responseSheet, row, responseMap, '是否多日活動') || (Number(activityDays || 0) > 1 ? '是' : '否');
    object['是否開放部分場次報名'] = readCellByHeader_(responseSheet, row, responseMap, '是否開放部分場次報名') || '否';
    object['是否需要每日簽到'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要每日簽到') || '是';
    object['是否需要課後回饋'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要課後回饋') || '是';
    object['是否需要成果繳交'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要成果繳交') || '是';
    object['報名截止日'] = readCellByHeader_(responseSheet, row, responseMap, '報名截止日');
    object['同意書回收日'] = readCellByHeader_(responseSheet, row, responseMap, '同意書回收日');
    object['活動地點'] = readCellByHeader_(responseSheet, row, responseMap, '活動地點');
    object['參加對象'] = readCellByHeader_(responseSheet, row, responseMap, '參加對象');
    object['預估人數'] = readCellByHeader_(responseSheet, row, responseMap, '預估人數');
    object['承辦處室'] = readCellByHeader_(responseSheet, row, responseMap, '承辦處室');
    object['承辦人'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人');
    object['承辦人Email'] = readCellByHeader_(responseSheet, row, responseMap, '承辦人Email');
    object['是否收費'] = readCellByHeader_(responseSheet, row, responseMap, '是否收費');
    object['費用說明'] = readCellByHeader_(responseSheet, row, responseMap, '費用說明');
    object['交通方式'] = readCellByHeader_(responseSheet, row, responseMap, '交通方式');
    object['是否需要保險'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要保險');
    object['是否需要家長同意書'] = readCellByHeader_(responseSheet, row, responseMap, '是否需要家長同意書');
    object['報名人數'] = 0;
    object['同意參加'] = 0;
    object['不參加'] = 0;
    object['特殊需求數'] = 0;
    object['目前狀態'] = '表單新增';
    object['最近更新時間'] = nowText_();
    object['備註'] = '由活動新增表單同步;表單時間戳記:' + formatDateIfNeeded_(timestamp);
    activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) { return object[header] || ''; }));
    writeActivityIntakeSyncNote_(
      responseSheet,
      row,
      responseMap,
      '已新增',
      '已新增到 01_活動清單,活動ID:' + activityId
    );
    const newRow = activitySheet.getLastRow();
    const activity = readActivityFromRow_(activitySheet, newRow, activityMap);
    generateSessionsForActivity_(activity);
    updateActivitySessionFields_({ sheet: activitySheet, row: newRow, headerMap: activityMap, activity: activity });
    existingKeys[key] = true;
    added++;
  }

  updateDashboard_();
  alert_('活動表單同步完成。新增到 01_活動清單:' + added + ' 筆;略過:' + skipped + ' 筆。');
  return added;
}

function writeActivityIntakeFormLinks_(ss, form) {
  const dashboard = getOrCreateSheet_(ss, CAMP_SHEETS.DASHBOARD);
  if (dashboard.getLastRow() < 69) setupDashboardSheet_(dashboard);
  ensureDashboardVisualLayout_(dashboard);
  dashboard.getRange('F4').setValue('活動新增表單').setFontWeight('bold');
  dashboard.getRange('G4').setValue(form.getPublishedUrl()).setWrap(true);
  dashboard.getRange('F5').setValue('活動新增表單編輯').setFontWeight('bold');
  dashboard.getRange('G5').setValue(form.getEditUrl()).setWrap(true);
  dashboard.getRange('F6').setValue('操作提醒').setFontWeight('bold');
  dashboard.getRange('G6').setValue('如果已使用「新增示範資料:新北AI研習」,不需要再執行「匯入活動新增表單回應到活動清單」。').setWrap(true);

  const settings = getOrCreateSheet_(ss, CAMP_SHEETS.SETTINGS);
  ensureHeaders_(settings, CAMP_SETTINGS_HEADERS);
  upsertSettingRow_(settings, '活動新增表單', form.getPublishedUrl(), '承辦人填寫後可同步到 01_活動清單');
  upsertSettingRow_(settings, '活動新增表單編輯', form.getEditUrl(), '講師或管理者調整表單欄位使用');
}

function getActivityIntakeResponseSheet_(ss) {
  const savedName = PropertiesService.getScriptProperties().getProperty('CAMP_ACTIVITY_INTAKE_RESPONSE_SHEET_NAME');
  if (savedName) {
    const sheet = ss.getSheetByName(savedName);
    if (sheet && looksLikeActivityIntakeResponseSheet_(sheet)) return sheet;
  }
  const sheets = ss.getSheets();
  for (let i = 0; i < sheets.length; i++) {
    if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
  }
  throw new Error('找不到活動新增表單回應分頁。請先執行「建立/開啟活動新增表單」,並送出至少一筆表單或確認表單已連結此試算表。');
}

function resolveActivityIntakeResponseSheet_(ss, beforeSheetIds) {
  const before = {};
  beforeSheetIds.forEach(function(id) {
    before[id] = true;
  });
  const sheets = ss.getSheets();
  for (let i = 0; i < sheets.length; i++) {
    if (!before[sheets[i].getSheetId()] && looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
  }
  for (let i = 0; i < sheets.length; i++) {
    if (looksLikeActivityIntakeResponseSheet_(sheets[i])) return sheets[i];
  }
  return sheets[sheets.length - 1];
}

function looksLikeActivityIntakeResponseSheet_(sheet) {
  if (sheet.getLastColumn() < 6) return false;
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
  return headers.indexOf('活動名稱') >= 0
    && headers.indexOf('活動類型') >= 0
    && headers.indexOf('承辦人Email') >= 0
    && headers.indexOf('是否需要家長同意書') >= 0;
}

function ensureActivityIntakeFormItems_(form) {
  const titles = {};
  form.getItems().forEach(function(item) {
    titles[item.getTitle()] = true;
  });
  if (!titles['活動開始日']) form.addDateItem().setTitle('活動開始日').setRequired(false);
  if (!titles['報名表模板類型']) form.addListItem().setTitle('報名表模板類型').setChoiceValues(CAMP_REGISTRATION_TEMPLATE_TYPES).setRequired(false);
  if (!titles['活動結束日']) form.addDateItem().setTitle('活動結束日').setRequired(false);
  if (!titles['每日預設開始時間']) form.addTextItem().setTitle('每日預設開始時間').setHelpText('例如 09:00').setRequired(false);
  if (!titles['每日預設結束時間']) form.addTextItem().setTitle('每日預設結束時間').setHelpText('例如 12:00').setRequired(false);
  if (!titles['是否多日活動']) form.addMultipleChoiceItem().setTitle('是否多日活動').setChoiceValues(['是', '否', '待確認']).setRequired(false);
  if (!titles['是否開放部分場次報名']) form.addMultipleChoiceItem().setTitle('是否開放部分場次報名').setChoiceValues(['是', '否']).setRequired(false);
  if (!titles['是否需要每日簽到']) form.addMultipleChoiceItem().setTitle('是否需要每日簽到').setChoiceValues(['是', '否']).setRequired(false);
  if (!titles['是否需要課後回饋']) form.addMultipleChoiceItem().setTitle('是否需要課後回饋').setChoiceValues(['是', '否']).setRequired(false);
  if (!titles['是否需要成果繳交']) form.addMultipleChoiceItem().setTitle('是否需要成果繳交').setChoiceValues(['是', '否']).setRequired(false);
}

function buildExistingActivityIntakeKeys_(activitySheet, activityMap) {
  const keys = {};
  for (let row = 2; row <= activitySheet.getLastRow(); row++) {
    const name = readCellByHeader_(activitySheet, row, activityMap, '活動名稱');
    const note = String(readCellByHeader_(activitySheet, row, activityMap, '備註') || '');
    const match = note.match(/表單時間戳記:([^\n]+)/);
    const timestamp = match ? match[1] : '';
    if (name && timestamp) {
      keys[buildActivityIntakeKey_(name, timestamp)] = true;
    }
  }
  return keys;
}

function buildActivityIntakeKey_(name, timestamp) {
  return String(name || '').trim() + '|' + String(timestamp || '').trim();
}

function upsertSettingRow_(sheet, key, value, note) {
  const map = ensureHeaders_(sheet, CAMP_SETTINGS_HEADERS);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (readCellByHeader_(sheet, row, map, '設定項目') === key) {
      writeCellByHeader_(sheet, row, map, '設定值', value);
      writeCellByHeader_(sheet, row, map, '說明', note);
      return;
    }
  }
  sheet.appendRow([key, value, note]);
}

function ensureActivityIntakeSubmitTrigger_(ss) {
  const functionName = 'syncActivityIntakeResponsesToActivityList';
  const exists = ScriptApp.getProjectTriggers().some(function(trigger) {
    return trigger.getHandlerFunction() === functionName
      && trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT;
  });
  if (!exists) {
    ScriptApp.newTrigger(functionName).forSpreadsheet(ss).onFormSubmit().create();
  }
}

function normalizeActivityName_(name) {
  return String(name || '')
    .trim()
    .toLowerCase()
    .replace(/[\s ]/g, '')
    .replace(/[||\--—–_::,,。.、//()()「」『』【】\[\]]/g, '');
}

function findExistingActivityByName_(sheet, headerMap, activityName) {
  const targetName = String(activityName || '').trim();
  const normalizedTarget = normalizeActivityName_(targetName);
  if (!targetName) return null;

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
    const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
    const existingStatus = String(readCellByHeader_(sheet, row, headerMap, '目前狀態') || '').trim();
    const existingNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();

    if (!existingName) continue;
    if (existingName === targetName || normalizeActivityName_(existingName) === normalizedTarget) {
      return {
        row: row,
        id: existingId,
        name: existingName,
        status: existingStatus,
        note: existingNote
      };
    }
  }
  return null;
}

function ensureActivityIntakeSyncColumns_(sheet) {
  const requiredHeaders = ['同步狀態', '同步時間', '同步備註'];
  let headers = sheet.getRange(1, 1, 1, Math.max(sheet.getLastColumn(), 1)).getValues()[0].map(String);

  requiredHeaders.forEach(function(header) {
    if (headers.indexOf(header) < 0) {
      sheet.getRange(1, sheet.getLastColumn() + 1).setValue(header);
      headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
    }
  });

  return buildHeaderMap_(sheet);
}

function writeActivityIntakeSyncNote_(sheet, row, responseMap, status, note) {
  writeCellByHeader_(sheet, row, responseMap, '同步狀態', status);
  writeCellByHeader_(sheet, row, responseMap, '同步時間', nowText_());
  writeCellByHeader_(sheet, row, responseMap, '同步備註', note);
}

function appendNoteToExistingActivity_(sheet, headerMap, row, note) {
  const currentNote = String(readCellByHeader_(sheet, row, headerMap, '備註') || '').trim();
  if (currentNote.indexOf(note) >= 0) return;

  const newNote = [
    currentNote,
    '',
    '【同步防呆紀錄】',
    note
  ].filter(Boolean).join('\n');

  writeCellByHeader_(sheet, row, headerMap, '備註', newNote);
  writeCellByHeader_(sheet, row, headerMap, '最近更新時間', nowText_());
}

檔案 11

ActivityService.gs|活動資料包建立

/**
 * 營隊/活動管理中樞:活動資料包建立與回填。
 */
function createActivityPackageFromSelectedRow() {
  const context = getSelectedActivityRowContext_();
  const result = ensureActivityPackageForContext_(context);
  updateDashboard_();
  alert_([
    '活動資料包已建立或確認存在。',
    '活動:' + result.activity.name,
    '資料夾:' + result.folder.getUrl(),
    '',
    '下一步可建立報名表單或行政文件模板。'
  ].join('\n'));
  return result.folder.getUrl();
}

function ensureActivityPackageForContext_(context) {
  initializeSheets_(context.ss);
  let activity = context.activity;
  if (!activity.id) {
    activity.id = generateActivityId_();
    writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動ID', activity.id);
  }

  let folder = getFolderFromActivityUrl_(activity.folderUrl);
  if (!folder) {
    folder = createActivityFolder_(activity);
    writeCellByHeader_(context.sheet, context.row, context.headerMap, '專案資料夾連結', folder.getUrl());
    activity.folderUrl = folder.getUrl();
  }

  const subfolders = createActivitySubfolders_(folder);
  generateSessionsForActivity_(activity);
  updateActivitySessionFields_(context);
  if (!activity.summaryDocUrl) {
    const summaryDoc = createActivitySummaryDoc_(activity, subfolders['00_活動基本資料']);
    writeCellByHeader_(context.sheet, context.row, context.headerMap, '活動摘要連結', summaryDoc.getUrl());
    appendDocumentListRow_(activity, '活動摘要', summaryDoc.getName(), '集中呈現活動基本資料與承辦人確認事項', summaryDoc.getUrl(), '系統模板', '草稿', '待確認', '建立活動資料包時產生');
  }

  appendDefaultTaskRows_(activity);
  appendDefaultNotificationRows_(activity);
  appendDefaultOutcomeRows_(activity);
  appendNotebookIndexRows_(activity);
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '目前狀態', '資料包已建立');
  writeCellByHeader_(context.sheet, context.row, context.headerMap, '最近更新時間', nowText_());
  return { activity: activity, folder: folder, subfolders: subfolders };
}

function ensureActivityPackageForSelectedActivity_() {
  const context = getSelectedActivityRowContext_();
  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  packageContext.activity = context.activity;
  packageContext.rowContext = context;
  return packageContext;
}

function createActivitySummaryDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_活動摘要_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('活動摘要').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('一、活動基本資料').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph(activityToText_(activity));
  body.appendParagraph('二、承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  [
    '活動日期、地點與時間是否正確。',
    '費用、收費方式、保險與交通是否符合校內程序。',
    '家長通知書與同意書正式發出前是否已核定。',
    '報名資料與 NotebookLM 匯出資料是否已排除不必要個資。'
  ].forEach(function(item) {
    body.appendParagraph('□ ' + item);
  });
  body.appendParagraph('三、AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph(CAMP_PROMPTS.activityDiagnosis.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
  body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function appendDefaultTaskRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.TASKS);
  ensureHeaders_(sheet, CAMP_TASK_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) sessions = generateSessionsForActivity_(activity);
  const startDate = effectiveActivityStartDate_(activity);
  const endDate = effectiveActivityEndDate_(activity) || startDate;
  const rows = [];
  [
    ['ALL', 'A01', '啟動', '建立活動資料包', '建立活動主資料夾、場次設定與中控台索引', '活動資料包', '資料夾與活動摘要已建立', '承辦人', activity.owner, '', addDays_(startDate, -30), '', '', '是', '活動摘要', '未開始', '高', '', ''],
    ['ALL', 'A02', '報名', '建立報名表單', '確認全程與部分場次報名欄位', '報名表單', '表單連結已回填活動清單', '承辦人', activity.owner, '', addDays_(startDate, -21), '', 'A01', '是', '報名表單', '未開始', '高', '', ''],
    ['ALL', 'A03', '通知', '發布活動通知', '家長或學員通知與處室通知完成確認', '通知文件', '正式通知已由承辦人確認', '承辦人', activity.owner, '', addDays_(startDate, -14), '', 'A02', '是', '家長通知書/處室通知', '未開始', '高', '', ''],
    ['ALL', 'A04', '名冊', '彙整總報名名單', '整理全程與部分場次報名統計', '報名統計', '場次報名人數已更新', '承辦人', activity.owner, '', activity.registrationDeadline, '', 'A02', '是', '報名資料', '未開始', '高', '', ''],
    ['ALL', 'A05', '行政確認', '確認收費/保險/交通', '確認活動行政風險與校內程序', '行政確認紀錄', '正式內容已確認', '承辦人', activity.owner, '', addDays_(startDate, -10), '', 'A03', '是', '標案需求表', '未開始', '高', '', ''],
    ['POST', 'P01', '結案', '建立 NotebookLM 資料包', '去識別化後整理多日活動資料包', 'NotebookLM資料包', '匯出文件已建立', '承辦人', activity.owner, '', addDays_(endDate, 7), '', '', '否', 'NotebookLM資料包', '未開始', '中', '', ''],
    ['POST', 'P02', '結案', '結案成果報告', '彙整成果、回饋、照片說明與下次建議', '成果報告素材', '成果檢查表已完成', '承辦人', activity.owner, '', addDays_(endDate, 14), '', 'P01', '否', '成果檢查表', '未開始', '中', '', '']
  ].forEach(function(item) {
    rows.push(buildTaskSheetRow_({
      '活動ID': activity.id,
      '場次ID': item[0],
      'WBS編碼': item[1],
      '階段': item[2],
      '任務名稱': item[3],
      '具體任務': item[4],
      '交付物': item[5],
      '完成判準': item[6],
      '負責角色': item[7],
      '負責人': item[8],
      '建議開始日期': item[9],
      '建議完成日期': item[10],
      '實際完成日期': item[11],
      '前置任務ID': item[12],
      '是否關鍵任務': item[13],
      '需要文件或表單': item[14],
      '狀態': item[15],
      '優先程度': item[16],
      '卡關原因': item[17],
      '備註': item[18],
      '最後更新時間': ''
    }));
  });

  sessions.forEach(function(session) {
    [
      ['課前', '確認本場講義', '確認本場講義、材料或簡報', '本場講義', '講義連結已放入場次設定', addDays_(parseDate_(session.date), -3), '高'],
      ['課前', '確認本場場地或線上連結', '確認場地、設備、線上會議連結與助教分工', '場地或線上連結', '場次地點與設備已確認', addDays_(parseDate_(session.date), -2), '高'],
      ['課前', '課前提醒學員', '提醒日期、時間、地點、攜帶物品與簽到方式', '課前提醒', '提醒已發出或公告', addDays_(parseDate_(session.date), -1), '中'],
      ['課前', '確認簽到表單', '確認逐場簽到資料與簽到流程', '簽到資料', '簽到預備列已建立', addDays_(parseDate_(session.date), -1), '高'],
      ['課後', '課後回收回饋', '回收本場回饋與問題紀錄', '回饋摘要', '回饋數已更新', parseDate_(session.date), '中'],
      ['課後', '整理本場成果', '整理照片、講義、錄影、回饋與成果說明', '本場成果資料', '成果整理狀態已更新', addDays_(parseDate_(session.date), 2), '中']
    ].forEach(function(item, index) {
      rows.push(buildTaskSheetRow_({
        '活動ID': activity.id,
        '場次ID': session.sessionId,
        'WBS編碼': session.sessionId + '-T' + ('0' + (index + 1)).slice(-2),
        '階段': item[0],
        '任務名稱': item[1],
        '具體任務': item[2],
        '交付物': item[3],
        '完成判準': item[4],
        '負責角色': '承辦人/協作人員',
        '負責人': activity.owner,
        '建議開始日期': '',
        '建議完成日期': item[5],
        '實際完成日期': '',
        '前置任務ID': '',
        '是否關鍵任務': item[6] === '高' ? '是' : '否',
        '需要文件或表單': item[3],
        '狀態': '未開始',
        '優先程度': item[6],
        '卡關原因': '',
        '備註': '',
        '最後更新時間': ''
      }));
    });
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_TASK_HEADERS.length).setValues(rows);
}

function buildTaskSheetRow_(taskData) {
  return CAMP_TASK_HEADERS.map(function(header) {
    return Object.prototype.hasOwnProperty.call(taskData, header) ? taskData[header] : '';
  });
}

function appendDefaultNotificationRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  const activityDate = parseDate_(activity.activityDate);
  const rows = [
    ['家長活動通知', '家長', '紙本/Email/班級群組', addDays_(activityDate, -14), '', activity.parentNoticeUrl || '', '草稿', '待確認', '正式發出前需確認日期、費用、交通與保險'],
    ['家長同意書回收提醒', '家長', '紙本/班級群組', activity.consentDeadline || activity.registrationDeadline, '', activity.parentConsentUrl || '', '未通知', '待確認', '追蹤未回覆名單'],
    ['導師行前提醒', '導師', '校內群組/Email', addDays_(activityDate, -7), '', activity.teacherReminderUrl || '', '草稿', '待確認', '確認點名與回報方式'],
    ['相關處室協調', '總務/學務/健康中心/會計', 'Email/會議', addDays_(activityDate, -14), '', activity.officeEmailUrl || '', '草稿', '待確認', '採購、保險、交通、安全與核銷'],
    ['成果資料收件提醒', '導師/協辦人', '校內群組/Email', addDays_(activityDate, 3), '', '', '未通知', '待確認', '活動後整理成果與照片說明']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTIFICATION_HEADERS.length).setValues(rows);
}

function appendDefaultOutcomeRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.OUTCOMES);
  ensureHeaders_(sheet, CAMP_OUTCOME_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) sessions = generateSessionsForActivity_(activity);
  const rows = [
    ['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '需補照片說明'],
    ['簽到或報名統計', '待確認', '', '是', '否', activity.owner, '含學生資料,不建議直接匯入 NotebookLM'],
    ['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可作為成果報告素材'],
    ['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
    ['經費或核銷附件', '不適用', '', '待確認', '否', activity.owner, '依校內規定處理'],
    ['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM 資料包']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  sessions.forEach(function(session) {
    [
      [session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
      [session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
      [session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
      [session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
    ].forEach(function(row) {
      rows.push([activity.id].concat(row));
    });
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
}

function appendNotebookIndexRows_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTEBOOK);
  ensureHeaders_(sheet, CAMP_NOTEBOOK_HEADERS);
  if (getRowsByActivityId_(sheet, activity.id).length) return;

  const rows = [
    ['活動基本資料', activityToText_(activity), '01_活動清單', '是', '不含敏感個資時可匯出'],
    ['報名統計', '報名人數、參加人數、不參加人數、特殊需求摘要', '02_報名資料', '是', '只放統計與摘要,不放電話與健康細節'],
    ['任務狀態', '任務追蹤與完成狀態', '03_任務追蹤', '是', ''],
    ['通知紀錄', '家長、導師、處室通知狀態', '05_通知管理', '是', '正式通知內容需承辦人確認'],
    ['成果檢查', '成果資料、照片說明、下次建議', '06_成果檢查', '是', ''],
    ['敏感資料', '學生姓名、電話、健康、身分資料、收費明細', '各表單原始回覆', '否', '不放入 NotebookLM 資料包']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_NOTEBOOK_HEADERS.length).setValues(rows);
}

檔案 12

RegistrationTemplateService.gs|報名表模板判斷

/**
 * 營隊/活動管理中樞:報名表模板與條件欄位判斷。
 */
function isYes_(value) {
  return String(value || '').trim() === '是';
}

function getRegistrationTemplateType_(activity) {
  const configured = String(activity.registrationTemplateType || '').trim();
  if (configured && configured !== '自動判斷') return configured;
  if (isWorkshopActivity_(activity) && isOnlineActivity_(activity)) return '線上研習';
  if (isWorkshopActivity_(activity)) return '教師研習';
  if (isStudentActivity_(activity)) return '學生營隊';
  return '自動判斷';
}

function isWorkshopActivity_(activity) {
  const template = String(activity.registrationTemplateType || '');
  const type = String(activity.type || '');
  const name = String(activity.name || '');
  if (template.indexOf('教師研習') >= 0 || template.indexOf('線上研習') >= 0) return true;
  return type.indexOf('研習') >= 0
    || type.indexOf('工作坊') >= 0
    || name.indexOf('研習') >= 0
    || name.indexOf('工作坊') >= 0;
}

function isStudentActivity_(activity) {
  const template = String(activity.registrationTemplateType || '');
  const type = String(activity.type || '');
  if (template.indexOf('學生營隊') >= 0 || template.indexOf('校外教學') >= 0 || template.indexOf('親子活動') >= 0 || template.indexOf('競賽活動') >= 0) return true;
  return type.indexOf('營隊') >= 0
    || type.indexOf('校外教學') >= 0
    || type.indexOf('校內活動') >= 0
    || type.indexOf('競賽') >= 0;
}

function isOnlineActivity_(activity) {
  const template = String(activity.registrationTemplateType || '');
  const location = String(activity.location || '');
  const transport = String(activity.transport || '');
  if (template.indexOf('線上研習') >= 0) return true;
  return location.indexOf('Google Meet') >= 0
    || location.indexOf('線上') >= 0
    || transport.indexOf('線上') >= 0;
}

function needsDietSurvey_(activity) {
  if (isOnlineActivity_(activity)) return false;
  const type = String(activity.type || '');
  const feeNote = String(activity.feeNote || '');
  const note = String(activity.note || '');
  return type.indexOf('營隊') >= 0
    || type.indexOf('校外教學') >= 0
    || feeNote.indexOf('餐') >= 0
    || note.indexOf('餐') >= 0
    || note.indexOf('便當') >= 0;
}

function needsInsuranceFields_(activity) {
  return isYes_(activity.insuranceRequired);
}

function needsConsentFields_(activity) {
  return isYes_(activity.consentRequired);
}

function needsPaymentFields_(activity) {
  return isYes_(activity.chargeRequired);
}

function needsPartialSessionFields_(activity) {
  return isYes_(activity.allowPartialSessions);
}

function buildWorkshopSessionChoiceValues_(activity, sessions) {
  if (String(activity.id || '') === 'ACT-NTPC-AI-11506') {
    return [
      'S01|6月15日(一) 課程主題|從公文啟動專案 主案例:教育局來文「AI 融入教學教師研習計畫」',
      'S02|6月18日(四) 課程主題|活動型專案管理 主案例:校外教學/親子活動辦理',
      'S03|6月22日(一) 課程主題|成果型專案與 AI 文件整理 主案例:教育優先區/補助計畫成果報告',
      'S04|6月25日(四) 課程主題|大型專案整合與制度化 主案例:運動會/校慶大型行政專案'
    ];
  }
  return buildSessionChoiceValues_(sessions);
}

檔案 13

RegistrationForm.gs|報名表單與回應同步

/**
 * 營隊/活動管理中樞:報名表單與回應同步。
 */
function createRegistrationFormForSelectedActivity() {
  const packageContext = getRegistrationPackageContextFromSelectedActivity_();
  if (!packageContext) return '';
  return createRegistrationFormForPackageContext_(packageContext);
}

function getRegistrationPackageContextFromSelectedActivity_() {
  const context = getSelectedActivityRowContext_();
  if (warnIfPossibleDuplicateNewTaipeiWorkshop_(context.activity)) {
    return null;
  }
  const packageContext = ensureActivityPackageForContext_(context);
  context.activity = readActivityFromRow_(context.sheet, context.row, context.headerMap);
  packageContext.activity = context.activity;
  packageContext.rowContext = context;
  return packageContext;
}

function warnIfPossibleDuplicateNewTaipeiWorkshop_(activity) {
  const name = String(activity.name || '');
  const id = String(activity.id || '');

  if (name.indexOf('新北AI共創大聯盟') >= 0 && id !== 'ACT-NTPC-AI-11506') {
    alert_([
      '你目前選取的活動名稱像是新北AI研習,但活動ID不是預設示範資料 ID。',
      '',
      '目前活動ID:' + valueOrPending_(id),
      '建議示範資料ID:ACT-NTPC-AI-11506',
      '',
      '如果這是重複同步產生的資料,請改選 ACT-NTPC-AI-11506 那一列。'
    ].join('\n'));
    return true;
  }

  return false;
}

function createRegistrationFormForPackageContext_(packageContext) {
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;

  if (activity.registrationFormUrl && activity.registrationFormEditUrl) {
    alert_('此活動已經有報名表單:\n' + activity.registrationFormUrl);
    return activity.registrationFormUrl;
  }
  generateSessionsForActivity_(activity);
  const sessions = getSessionsByActivityId_(activity.id);

  const form = FormApp.create('報名表單_' + activity.name);
  form.setDescription([
    '活動名稱:' + activity.name,
    '活動日期:' + formatDateValue_(activity.activityDate),
    '活動地點:' + valueOrPending_(activity.location),
    '報名截止日:' + formatDateValue_(activity.registrationDeadline),
    '',
    '個資使用提醒:本表單資料僅供本次活動報名、通知、保險、聯繫與行政確認使用。正式保存、分享與刪除仍依校內規範辦理。'
  ].join('\n'));
  form.setAllowResponseEdits(true);
  form.setDestination(FormApp.DestinationType.SPREADSHEET, rowContext.ss.getId());

  const activityIdItem = form.addTextItem().setTitle('活動ID').setRequired(true).setHelpText('系統欄位,請勿修改。');
  const activityNameItem = form.addTextItem().setTitle('活動名稱').setRequired(true).setHelpText('系統欄位,請勿修改。');

  if (isWorkshopActivity_(activity)) {
    form.addTextItem().setTitle('單位').setRequired(true);
    form.addTextItem().setTitle('科別/職稱').setRequired(true);
    form.addTextItem().setTitle('姓名').setRequired(true);
    form.addTextItem().setTitle('Email').setRequired(false);
    form.addCheckboxItem()
      .setTitle('參加場次')
      .setChoiceValues(buildWorkshopSessionChoiceValues_(activity, sessions))
      .setRequired(true);
    form.addMultipleChoiceItem()
      .setTitle('需要研習時數')
      .setChoiceValues(['需要', '不需要', '待確認'])
      .setRequired(false);
    form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);
    form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
  } else {
    form.addTextItem().setTitle('學生姓名').setRequired(true);
    form.addTextItem().setTitle('班級').setRequired(true);
    form.addTextItem().setTitle('座號').setRequired(false);
    form.addTextItem().setTitle('家長姓名').setRequired(true);
    form.addTextItem().setTitle('家長Email').setRequired(false);
    form.addTextItem().setTitle('家長電話').setRequired(false);
    form.addMultipleChoiceItem().setTitle('是否參加').setChoiceValues(['參加', '不參加', '需再確認']).setRequired(true);

    if (needsPartialSessionFields_(activity)) {
      form.addMultipleChoiceItem().setTitle('是否全程參加').setChoiceValues(['是,我會全程參加', '否,我只參加部分場次']).setRequired(true);
      form.addCheckboxItem().setTitle('選擇參加場次').setChoiceValues(buildSessionChoiceValues_(sessions)).setRequired(false);
    }

    form.addMultipleChoiceItem().setTitle('需要研習時數').setChoiceValues(['需要', '不需要', '待確認']).setRequired(false);
    form.addParagraphTextItem().setTitle('學習期待').setRequired(false);
    form.addParagraphTextItem().setTitle('目前行政痛點').setRequired(false);

    if (needsDietSurvey_(activity)) {
      form.addMultipleChoiceItem().setTitle('飲食需求').setChoiceValues(['無特殊需求', '葷食', '素食', '其他']).setRequired(false);
    }

    if (needsInsuranceFields_(activity)) {
      form.addTextItem().setTitle('出生日期').setRequired(false);
      form.addTextItem().setTitle('身分證字號').setRequired(false);
      form.addMultipleChoiceItem().setTitle('是否同意提供保險所需資料').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
    }

    if (needsConsentFields_(activity)) {
      form.addMultipleChoiceItem().setTitle('家長是否同意參加').setChoiceValues(['同意', '不同意', '需再確認']).setRequired(true);
      form.addMultipleChoiceItem().setTitle('同意書狀態').setChoiceValues(['已繳交', '尚未繳交', '不適用']).setRequired(false);
    }

    if (needsPaymentFields_(activity)) {
      form.addMultipleChoiceItem().setTitle('繳費方式').setChoiceValues(['現金', '轉帳', '學校代收', '其他', '待確認']).setRequired(false);
      form.addParagraphTextItem().setTitle('繳費備註').setRequired(false);
    }

    form.addCheckboxItem().setTitle('特殊需求').setChoiceValues(['無', '暈車', '醫療需求', '過敏', '交通接送', '其他']).setRequired(false);
  }

  form.addMultipleChoiceItem().setTitle('是否同意通知').setChoiceValues(['同意', '不同意']).setRequired(true);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);

  const prefilledUrl = form.createResponse()
    .withItemResponse(activityIdItem.createResponse(activity.id))
    .withItemResponse(activityNameItem.createResponse(activity.name))
    .toPrefilledUrl();

  moveFileToFolder_(form.getId(), packageContext.subfolders['01_報名表單與回覆']);
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單連結', prefilledUrl);
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名表單編輯連結', form.getEditUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '報名回應表連結', rowContext.ss.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());

  appendDocumentListRow_(activity, '報名表單', form.getTitle(), '收集活動報名、場次選擇與行政需求資料', prefilledUrl, 'Google Form', '待確認', '待確認', '發出前需確認欄位與個資告知文字;模板:' + getRegistrationTemplateType_(activity));
  updateDashboard_();
  alert_('報名表單已建立:\n' + prefilledUrl);
  return prefilledUrl;
}

function syncRegistrationResponses() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const targetSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  ensureHeaders_(targetSheet, CAMP_REGISTRATION_HEADERS);
  const existingKeys = buildExistingRegistrationKeys_(targetSheet);
  const systemSheetNames = {};
  CAMP_SHEET_ORDER.forEach(function(name) {
    systemSheetNames[name] = true;
  });

  let added = 0;
  ss.getSheets().forEach(function(sheet) {
    if (systemSheetNames[sheet.getName()]) return;
    if (!looksLikeRegistrationResponseSheet_(sheet)) return;
    const values = sheet.getDataRange().getValues();
    const headers = values[0];
    const index = buildArrayHeaderIndex_(headers);
    for (let i = 1; i < values.length; i++) {
      const row = values[i];
      const activityId = readArrayCell_(row, index, '活動ID');
      const studentName = readArrayCell_(row, index, '學生姓名')
        || readArrayCell_(row, index, '姓名');
      const unit = readArrayCell_(row, index, '班級')
        || readArrayCell_(row, index, '單位');
      const title = readArrayCell_(row, index, '座號')
        || readArrayCell_(row, index, '科別/職稱')
        || readArrayCell_(row, index, '科別/職稱')
        || readArrayCell_(row, index, '科別/ 職稱');
      const activityName = readArrayCell_(row, index, '活動名稱');
      const timestamp = readArrayCell_(row, index, '時間戳記') || readArrayCell_(row, index, 'Timestamp');
      if (!activityId || !studentName) continue;
      const key = [activityId, studentName, timestamp].join('|');
      if (existingKeys[key]) continue;
      const selectedText = readArrayCell_(row, index, '選擇參加場次') || readArrayCell_(row, index, '參加場次');
      const fullParticipation = readArrayCell_(row, index, '是否全程參加')
        || inferFullParticipationFromSelectedSessions_(selectedText);
      const selectedSessions = normalizeSelectedSessions_(activityId, fullParticipation, selectedText);
      const email = readArrayCell_(row, index, '家長Email') || readArrayCell_(row, index, 'Email');
      targetSheet.appendRow([
        activityId,
        activityName,
        timestamp,
        studentName,
        unit,
        title,
        readArrayCell_(row, index, '家長姓名'),
        email,
        readArrayCell_(row, index, '家長電話'),
        readArrayCell_(row, index, '是否參加') || '參加',
        fullParticipation,
        selectedSessions,
        readArrayCell_(row, index, '需要研習時數'),
        readArrayCell_(row, index, '學習期待'),
        readArrayCell_(row, index, '目前行政痛點'),
        readArrayCell_(row, index, '是否同意通知'),
        readArrayCell_(row, index, '飲食需求'),
        readArrayCell_(row, index, '特殊需求'),
        readArrayCell_(row, index, '同意書狀態'),
        '否',
        sheet.getName(),
        buildRegistrationNote_(row, index, title)
      ]);
      ensureAttendanceRowsForRegistration_({
        activityId: activityId,
        name: studentName,
        unit: unit,
        email: email,
        fullParticipation: fullParticipation,
        selectedSessions: selectedSessions
      });
      existingKeys[key] = true;
      added++;
    }
  });

  updateRegistrationStats_();
  updateSessionStats_();
  updateDashboard_();
  alert_('報名資料同步完成。新增 ' + added + ' 筆。');
  return added;
}

function buildSessionChoiceValues_(sessions) {
  if (!sessions.length) return ['S01|第1場'];
  return sessions.map(function(session) {
    return [
      session.sessionId,
      formatDateValue_(session.date),
      session.title || ('第' + session.sessionId.replace('S', '') + '場'),
      session.topic || ''
    ].filter(String).join('|');
  });
}

function normalizeSelectedSessions_(activityId, fullParticipation, selectedText) {
  const sessions = getSessionsByActivityId_(activityId);
  if (String(fullParticipation || '').indexOf('全程') >= 0 || String(fullParticipation || '') === '是') {
    return sessions.map(function(session) { return session.sessionId; }).join(',');
  }
  const selected = parseSelectedSessionIds_(selectedText);
  if (selected.length) return selected.join(',');
  return sessions.length === 1 ? sessions[0].sessionId : '';
}

function inferFullParticipationFromSelectedSessions_(selectedText) {
  const count = parseSelectedSessionIds_(selectedText).length;
  return count >= 4 ? '是,我會全程參加' : '否,我只參加部分場次';
}

function buildRegistrationNote_(row, index, title) {
  const note = String(readArrayCell_(row, index, '備註') || '').trim();
  const workshopTitle = String(title || '').trim();
  if (!workshopTitle) return note;
  return [note, '科別/職稱:' + workshopTitle].filter(String).join('\n');
}

function updateRegistrationStats_() {
  const ss = getActiveSpreadsheet_();
  const registrationSheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const registrationRows = getRegistrationStatsByActivity_(registrationSheet);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);

  for (let row = 2; row <= activitySheet.getLastRow(); row++) {
    const activityId = readCellByHeader_(activitySheet, row, activityMap, '活動ID');
    if (!activityId) continue;
    const stats = registrationRows[String(activityId)] || { total: 0, join: 0, notJoin: 0, special: 0 };
    writeCellByHeader_(activitySheet, row, activityMap, '報名人數', stats.total);
    writeCellByHeader_(activitySheet, row, activityMap, '同意參加', stats.join);
    writeCellByHeader_(activitySheet, row, activityMap, '不參加', stats.notJoin);
    writeCellByHeader_(activitySheet, row, activityMap, '特殊需求數', stats.special);
    writeCellByHeader_(activitySheet, row, activityMap, '最近更新時間', nowText_());
  }
}

function getRegistrationStatsByActivity_(sheet) {
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
  const map = buildHeaderMap_(sheet);
  const stats = {};
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
    if (!activityId) continue;
    if (!stats[activityId]) stats[activityId] = { total: 0, join: 0, notJoin: 0, special: 0 };
    stats[activityId].total++;
    const participate = String(readCellByHeader_(sheet, row, map, '是否參加') || '');
    if (participate === '參加') stats[activityId].join++;
    if (participate === '不參加') stats[activityId].notJoin++;
    const special = String(readCellByHeader_(sheet, row, map, '特殊需求') || '').trim();
    if (special && special !== '無') stats[activityId].special++;
  }
  return stats;
}

function buildExistingRegistrationKeys_(sheet) {
  const map = buildHeaderMap_(sheet);
  const keys = {};
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const key = [
      readCellByHeader_(sheet, row, map, '活動ID'),
      readCellByHeader_(sheet, row, map, '學生姓名'),
      readCellByHeader_(sheet, row, map, '時間戳記')
    ].join('|');
    keys[key] = true;
  }
  return keys;
}

function looksLikeRegistrationResponseSheet_(sheet) {
  if (sheet.getLastRow() < 1 || sheet.getLastColumn() < 3) return false;
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
  const hasName = headers.indexOf('學生姓名') >= 0 || headers.indexOf('姓名') >= 0;
  const hasParticipation = headers.indexOf('是否參加') >= 0
    || headers.indexOf('選擇參加場次') >= 0
    || headers.indexOf('參加場次') >= 0;
  return headers.indexOf('活動ID') >= 0 && hasName && hasParticipation;
}

function buildArrayHeaderIndex_(headers) {
  const index = {};
  headers.forEach(function(header, i) {
    if (header) index[String(header).trim()] = i;
  });
  return index;
}

function readArrayCell_(row, index, header) {
  return index[header] === undefined ? '' : row[index[header]];
}

檔案 14

SeedRegistrationData.gs|教學用50筆假報名資料

/**
 * 營隊/活動管理中樞:教學用報名假資料。
 */
function createRegistrationFormAndSeedSampleData() {
  const packageContext = getRegistrationPackageContextFromSelectedActivity_();
  if (!packageContext) return '';
  const formUrl = createRegistrationFormForPackageContext_(packageContext);
  const seeded = seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
  alert_([
    '報名表單已建立或確認存在。',
    '',
    '本次新增假報名資料:' + seeded + ' 筆。',
    '已更新報名統計、場次統計、簽到預備資料與中控台。',
    '',
    '報名表單:',
    formUrl
  ].join('\n'));
  return formUrl;
}

function seedSampleWorkshopRegistrationsForSelectedActivity() {
  const packageContext = getRegistrationPackageContextFromSelectedActivity_();
  if (!packageContext) return 0;
  return seedSampleWorkshopRegistrationsForPackageContext_(packageContext);
}

function seedSampleWorkshopRegistrationsForPackageContext_(packageContext) {
  const activity = packageContext.activity;

  if (!activity.id) {
    throw new Error('此活動缺少活動ID,請先建立活動資料包或補上活動ID。');
  }

  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);

  const existingRows = getRowsByActivityId_(sheet, activity.id);
  const hasOfficialRows = existingRows.some(function(row) {
    return String(row['來源分頁'] || '') !== '系統假資料';
  });
  if (hasOfficialRows) {
    alert_('此活動已有正式報名資料,為避免混入教學假資料,本次不自動產生 50 筆假資料。');
    return 0;
  }
  if (existingRows.length >= 50) {
    alert_('此活動已有 ' + existingRows.length + ' 筆報名資料,不重複 seed 50 筆。');
    return 0;
  }

  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) {
    generateSessionsForActivity_(activity);
    sessions = getSessionsByActivityId_(activity.id);
  }

  const remaining = Math.max(0, 50 - existingRows.length);
  const sampleRows = buildSampleWorkshopRegistrationRows_(activity, remaining, existingRows.length);
  if (!sampleRows.length) return 0;

  sheet.getRange(sheet.getLastRow() + 1, 1, sampleRows.length, CAMP_REGISTRATION_HEADERS.length)
    .setValues(sampleRows);

  sampleRows.forEach(function(row) {
    ensureAttendanceRowsForRegistration_({
      activityId: row[CAMP_REGISTRATION_HEADERS.indexOf('活動ID')],
      name: row[CAMP_REGISTRATION_HEADERS.indexOf('學生姓名')],
      unit: row[CAMP_REGISTRATION_HEADERS.indexOf('班級')],
      email: row[CAMP_REGISTRATION_HEADERS.indexOf('家長Email')],
      fullParticipation: row[CAMP_REGISTRATION_HEADERS.indexOf('是否全程參加')],
      selectedSessions: row[CAMP_REGISTRATION_HEADERS.indexOf('選擇參加場次')]
    });
  });

  updateRegistrationStats_();
  updateSessionStats_();
  updateDashboard_();
  return sampleRows.length;
}

function buildSampleWorkshopRegistrationRows_(activity, count, offset) {
  const units = [
    '新北市政府教育局', '八里國小', '三和國中', '三芝國中', '三義高中',
    '竹南國小', '新莊國小', '板橋國小', '泰山高中', '淡水國中',
    '一般企業', '一般民眾', '人力學院', '教育科技中心', '資訊教育輔導團'
  ];
  const titles = [
    '國小教育科', '主任', '教師', '科任教師', '資訊組長',
    '教務主任', '學務主任', '行政人員', '採購', '上班族',
    '綜合規劃組', '輔導員', '研究教師', '自然科教師', '總務主任'
  ];
  const names = [
    '王品安', '林書宇', '陳郁庭', '張雅筑', '李承恩',
    '黃子晴', '劉冠廷', '吳怡萱', '蔡孟潔', '楊皓宇',
    '許庭瑄', '鄭宇翔', '謝佳穎', '洪柏翰', '郭采蓉',
    '曾昱廷', '邱郁涵', '廖品妤', '賴冠宇', '周宜蓁',
    '徐子軒', '蘇映潔', '葉哲維', '江佩珊', '何宥辰',
    '蕭雅雯', '羅品萱', '高承翰', '潘怡君', '游宗翰',
    '程心瑜', '方柏宇', '石婉婷', '沈昱翔', '戴佳蓉',
    '彭宇恩', '詹雅婷', '卓冠霖', '簡若涵', '藍柏翰',
    '梁芷晴', '康家豪', '姜宜庭', '白承祐', '任品妍',
    '歐陽子晴', '范庭瑋', '宋宜蓁', '古承恩', '杜欣妤'
  ];
  const painPoints = [
    '資料分散在不同雲端資料夾,常常找不到最新版。',
    '活動報名、通知、名冊整理需要重複複製貼上。',
    '公文轉成待辦任務時,容易漏掉期限。',
    '想把表單、試算表、文件和提醒整合起來。',
    '希望學會用 AI 協助整理成果報告草稿。',
    '行政流程常需要跨處室協作,希望能更清楚追蹤。',
    '想知道 NotebookLM 可以如何用在行政資料整理。',
    '希望了解 Apps Script 在校務行政的實際應用。'
  ];
  const expectations = [
    '希望能完成一套可複製的行政專案流程。',
    '想學會從公文建立專案追蹤表。',
    '希望能降低人工整理資料的時間。',
    '想把活動報名與成果整理變得更有系統。',
    '希望能做出適合學校同仁使用的範本。',
    '想理解 AI 在行政流程中的合理位置。',
    '希望回校後能實際套用在研習或活動辦理。',
    '想學會建立 NotebookLM 資料包。'
  ];

  const rows = [];
  for (let i = 0; i < count; i++) {
    const serial = offset + i;
    const name = names[serial % names.length];
    const unit = units[serial % units.length];
    const title = titles[serial % titles.length];
    const email = 'sample' + ('000' + (serial + 1)).slice(-3) + '@example.com';
    const selectedSessions = buildSeedSelectedSessions_(serial);
    const fullParticipation = selectedSessions === 'S01,S02,S03,S04' ? '是,我會全程參加' : '否,我只參加部分場次';
    const timestamp = new Date(2026, 5, 1 + (serial % 5), 9 + (serial % 10), (serial * 7) % 60, (serial * 11) % 60);
    const object = {};
    object['活動ID'] = activity.id;
    object['活動名稱'] = activity.name;
    object['時間戳記'] = timestamp;
    object['學生姓名'] = name;
    object['班級'] = unit;
    object['座號'] = title;
    object['家長姓名'] = '';
    object['家長Email'] = email;
    object['家長電話'] = '';
    object['是否參加'] = '參加';
    object['是否全程參加'] = fullParticipation;
    object['選擇參加場次'] = selectedSessions;
    object['需要研習時數'] = serial % 4 === 0 ? '不需要' : '需要';
    object['學習期待'] = expectations[serial % expectations.length];
    object['目前行政痛點'] = painPoints[serial % painPoints.length];
    object['是否同意通知'] = '同意';
    object['飲食需求'] = '';
    object['特殊需求'] = '';
    object['同意書狀態'] = '不適用';
    object['是否可提供 NotebookLM'] = '是';
    object['來源分頁'] = '系統假資料';
    object['備註'] = '教師研習示範資料,非真實個資。單位:' + unit + ';職稱:' + title;
    rows.push(CAMP_REGISTRATION_HEADERS.map(function(header) {
      return object[header] || '';
    }));
  }
  return rows;
}

function buildSeedSelectedSessions_(index) {
  const patterns = [
    'S01,S02,S03,S04',
    'S01,S02,S03,S04',
    'S01,S02,S03,S04',
    'S01,S02,S03,S04',
    'S01',
    'S02',
    'S03',
    'S04',
    'S01,S02',
    'S02,S03',
    'S03,S04',
    'S01,S04',
    'S01,S03',
    'S02,S04',
    'S01,S02,S03',
    'S02,S03,S04'
  ];
  return patterns[index % patterns.length];
}

function clearSeedRegistrationDataForSelectedActivity() {
  const context = getSelectedActivityRowContext_();
  const activity = context.activity;
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.REGISTRATIONS);
  ensureHeaders_(sheet, CAMP_REGISTRATION_HEADERS);
  const map = buildHeaderMap_(sheet);

  for (let row = sheet.getLastRow(); row >= 2; row--) {
    const activityId = String(readCellByHeader_(sheet, row, map, '活動ID') || '');
    const source = String(readCellByHeader_(sheet, row, map, '來源分頁') || '');
    if (activityId === activity.id && source === '系統假資料') {
      sheet.deleteRow(row);
    }
  }

  updateRegistrationStats_();
  updateSessionStats_();
  updateDashboard_();
  alert_('已清除本活動的系統假報名資料。');
}

檔案 15

DocumentTemplates.gs|行政文件模板

/**
 * 營隊/活動管理中樞:行政文件模板。
 */
function createDocumentTemplatesForSelectedActivity() {
  const packageContext = ensureActivityPackageForSelectedActivity_();
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;
  const subfolders = packageContext.subfolders;

  const parentNotice = createParentNoticeDoc_(activity, subfolders['02_家長通知與同意書']);
  const parentConsent = createParentConsentDoc_(activity, subfolders['02_家長通知與同意書']);
  const procurement = createProcurementRequirementDoc_(activity, subfolders['03_處室聯繫與採購']);
  const officeEmail = createOfficeEmailDraftDoc_(activity, subfolders['03_處室聯繫與採購']);
  const teacherReminder = createTeacherReminderDoc_(activity, subfolders['03_處室聯繫與採購']);
  const outcomeSheet = createOutcomeChecklistSheet_(activity, subfolders['06_成果檢查與結案']);

  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長通知書連結', parentNotice.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '家長同意書連結', parentConsent.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '標案需求表連結', procurement.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '處室通知草稿連結', officeEmail.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '導師行前提醒連結', teacherReminder.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '成果檢查表連結', outcomeSheet.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '目前狀態', '文件模板已建立');
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());

  activity.parentNoticeUrl = parentNotice.getUrl();
  activity.parentConsentUrl = parentConsent.getUrl();
  activity.procurementUrl = procurement.getUrl();
  activity.officeEmailUrl = officeEmail.getUrl();
  activity.teacherReminderUrl = teacherReminder.getUrl();
  activity.outcomeUrl = outcomeSheet.getUrl();

  appendDocumentListRow_(activity, '家長通知書', parentNotice.getName(), '提供家長活動資訊與回覆提醒', parentNotice.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '家長同意書', parentConsent.getName(), '提供家長簽署或確認參加意願', parentConsent.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '標案需求表', procurement.getName(), '整理採購、交通、保險或物資需求', procurement.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '處室通知草稿', officeEmail.getName(), '提供總務、學務、健康中心、會計或協辦處室通知草稿', officeEmail.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '導師行前提醒', teacherReminder.getName(), '提供導師活動前與活動當日提醒事項', teacherReminder.getUrl(), '系統模板', '草稿', '待確認', '');
  appendDocumentListRow_(activity, '成果檢查表', outcomeSheet.getName(), '檢查活動後成果、照片、附件與 NotebookLM 可用資料', outcomeSheet.getUrl(), '系統模板', '草稿', '待確認', '');

  refreshNotificationDocumentLinks_(activity);
  updateDashboard_();
  alert_('行政文件模板已建立完成。請逐份確認後再正式公告、寄送或送簽。');
}

function createParentNoticeDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_家長通知書_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('家長通知書').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('親愛的家長您好:');
  body.appendParagraph('本校預計辦理「' + activity.name + '」,以下為活動資訊草稿,正式內容仍以承辦人確認後公告為準。');
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['活動開始日', formatDateValue_(activity.startDate)],
    ['活動結束日', formatDateValue_(activity.endDate)],
    ['活動地點', valueOrPending_(activity.location)],
    ['參加對象', valueOrPending_(activity.participants)],
    ['報名截止日', formatDateValue_(activity.registrationDeadline)],
    ['費用說明', valueOrPending_(activity.feeNote)],
    ['交通方式', valueOrPending_(activity.transport)],
    ['保險需求', valueOrPending_(activity.insuranceRequired)]
  ]);
  body.appendParagraph('場次表').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendSessionsTable_(body, activity.id);
  body.appendParagraph('家長需協助事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph('請於期限內完成報名或同意書回覆,並留意導師或學校後續通知。');
  body.appendParagraph('AI 提示詞').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph(CAMP_PROMPTS.parentNotice.replace('{{ACTIVITY_DATA}}', activityToText_(activity)));
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['日期、時間、地點正確', '費用與繳交方式正確', '交通、保險與安全資訊已確認', '正式發出前已依校內程序核定']);
  body.appendParagraph(CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createParentConsentDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_家長同意書_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('家長同意書').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('本人已詳閱「' + activity.name + '」活動說明,並同意學生依學校規劃參與本次活動。');
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['活動地點', valueOrPending_(activity.location)],
    ['同意書回收日', formatDateValue_(activity.consentDeadline)],
    ['是否收費', valueOrPending_(activity.chargeRequired)],
    ['是否需要保險', valueOrPending_(activity.insuranceRequired)]
  ]);
  body.appendParagraph('學生姓名:____________________    班級座號:____________________');
  body.appendParagraph('家長簽名:____________________    聯絡電話:____________________');
  body.appendParagraph('特殊需求或注意事項:');
  body.appendParagraph('\n\n');
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['同意書欄位符合校內格式', '未收集不必要個資', '回收期限與保管方式已確認']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createProcurementRequirementDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_標案採購需求表_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('標案/採購需求表').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('本文件用於整理活動所需之交通、場地、保險、餐點、材料、設備或其他採購需求草稿。正式採購仍依校內與法規程序辦理。');
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['預估人數', valueOrPending_(activity.estimatedCount)],
    ['活動地點', valueOrPending_(activity.location)],
    ['交通方式', valueOrPending_(activity.transport)],
    ['費用說明', valueOrPending_(activity.feeNote)],
    ['承辦處室', valueOrPending_(activity.department)],
    ['承辦人', valueOrPending_(activity.owner)]
  ]);
  body.appendParagraph('需求項目草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendDocTable_(body, [
    ['需求類別', '需求內容', '數量', '規格', '預估日期', '備註'],
    ['交通', activity.transport || '待確認', activity.estimatedCount || '', '', formatDateValue_(activity.activityDate), ''],
    ['保險', activity.insuranceRequired || '待確認', activity.estimatedCount || '', '', '', ''],
    ['餐點或材料', '待確認', '', '', '', '']
  ]);
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['是否需要總務或會計確認', '採購方式是否符合規定', '經費來源與核銷附件是否明確']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createOfficeEmailDraftDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_處室通知Email草稿_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('處室通知 Email 草稿').setHeading(DocumentApp.ParagraphHeading.TITLE);
  body.appendParagraph('主旨:【活動協作通知】' + activity.name);
  body.appendParagraph('收件對象:總務處、學務處、健康中心、會計室、導師或協辦同仁(請承辦人依實際情況調整)');
  body.appendParagraph('Email 內容草稿').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph([
    '各位同仁您好:',
    '',
    '本校預計辦理「' + activity.name + '」,活動日期為 ' + formatDateValue_(activity.activityDate) + ',地點為 ' + valueOrPending_(activity.location) + '。',
    '敬請協助確認相關行政事項:',
    '1. 交通、場地或物資需求。',
    '2. 保險、健康或特殊需求學生提醒。',
    '3. 經費、採購、核銷或成果附件需求。',
    '4. 活動當日安全、集合、點名與回報方式。',
    '',
    '相關資料夾或文件連結請見中控台。',
    '',
    '承辦人:' + valueOrPending_(activity.owner),
    '承辦處室:' + valueOrPending_(activity.department)
  ].join('\n'));
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['收件對象是否正確', '不包含不應外寄的學生個資', '採購、保險、交通與安全內容已確認']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createTeacherReminderDoc_(activity, folder) {
  const doc = DocumentApp.create(activity.id + '_導師行前提醒_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  body.appendParagraph('導師/工作人員行前提醒').setHeading(DocumentApp.ParagraphHeading.TITLE);
  appendDocTable_(body, [
    ['活動名稱', activity.name],
    ['活動日期', formatDateValue_(activity.activityDate)],
    ['活動地點', valueOrPending_(activity.location)],
    ['參加對象', valueOrPending_(activity.participants)]
  ]);
  body.appendParagraph('活動前一週').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['確認參加名冊', '提醒學生攜帶物品', '確認特殊需求或醫療提醒']);
  const sessions = getSessionsByActivityId_(activity.id);
  if (sessions.length > 1) {
    body.appendParagraph('逐場提醒').setHeading(DocumentApp.ParagraphHeading.HEADING1);
    sessions.forEach(function(session) {
      body.appendParagraph(session.sessionId + '|' + formatDateValue_(session.date) + '|' + valueOrPending_(session.title)).setHeading(DocumentApp.ParagraphHeading.HEADING2);
      appendChecklistParagraphs_(body, [
        '確認本場地點或線上連結:' + valueOrPending_(session.location),
        '確認本場簽到方式與名冊',
        '確認本場講義、設備與回報方式'
      ]);
    });
  }
  body.appendParagraph('活動前一天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['再次提醒集合時間與地點', '確認未回覆或特殊需求學生', '確認緊急聯絡方式']);
  body.appendParagraph('活動當天').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['集合點名', '活動中定時確認人數', '返校或結束後回報']);
  body.appendParagraph('承辦人確認事項').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  appendChecklistParagraphs_(body, ['提醒內容是否符合實際活動', '不公開不必要個資', '安全與回報方式已確認']);
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function createOutcomeChecklistSheet_(activity, folder) {
  const ss = SpreadsheetApp.create(activity.id + '_成果檢查表_' + sanitizeFileName_(activity.name));
  const sheet = ss.getSheets()[0];
  sheet.setName('成果檢查');
  sheet.getRange(1, 1, 1, CAMP_OUTCOME_HEADERS.length).setValues([CAMP_OUTCOME_HEADERS]);
  const rows = [
    ['活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '請補照片說明'],
    ['活動流程紀錄', '待確認', '', '否', '是', activity.owner, '可用於成果報告'],
    ['參與統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放學生個資'],
    ['學生或家長回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
    ['下次辦理建議', '待確認', '', '否', '是', activity.owner, '可放入 NotebookLM']
  ].map(function(row) {
    return [activity.id].concat(row);
  });
  let sessions = getSessionsByActivityId_(activity.id);
  if (!sessions.length) sessions = generateSessionsForActivity_(activity);
  sessions.forEach(function(session) {
    [
      [session.sessionId + ' 活動照片', '待確認', '', '待確認', '去識別化後可', activity.owner, '逐場照片與照片說明'],
      [session.sessionId + ' 簽到統計', '待確認', '', '否', '是', activity.owner, '只放統計,不放完整簽到名冊'],
      [session.sessionId + ' 回饋摘要', '待確認', '', '是', '去識別化後可', activity.owner, '先去識別化'],
      [session.sessionId + ' 講義或錄影', '待確認', '', '待確認', '是', activity.owner, '確認是否可公開或匯入 NotebookLM']
    ].forEach(function(row) {
      rows.push([activity.id].concat(row));
    });
  });
  sheet.getRange(2, 1, rows.length, CAMP_OUTCOME_HEADERS.length).setValues(rows);
  formatHeaderRow_(sheet, CAMP_OUTCOME_HEADERS.length);
  moveFileToFolder_(ss.getId(), folder);
  return ss;
}

function appendDocTable_(body, rows) {
  const table = body.appendTable(rows);
  table.setBorderWidth(1);
  return table;
}

function appendChecklistParagraphs_(body, items) {
  items.forEach(function(item) {
    body.appendParagraph('□ ' + item);
  });
}

檔案 16

NotificationService.gs|通知測試與紀錄

/**
 * 營隊/活動管理中樞:通知草稿與測試信。
 */
function sendTestEmailToOwner() {
  const context = getSelectedActivityRowContext_();
  const activity = context.activity;
  if (!activity.ownerEmail) throw new Error('此活動缺少「承辦人Email」,無法寄送測試信。');

  const subject = '【測試信】' + activity.name + ' 活動通知草稿檢查';
  const body = [
    '這是營隊活動管理中樞寄出的測試信,尚未寄送給家長或其他處室。',
    '',
    '活動名稱:' + activity.name,
    '活動日期:' + formatDateValue_(activity.activityDate),
    '活動地點:' + valueOrPending_(activity.location),
    '',
    '重要文件連結:',
    '專案資料夾:' + valueOrPending_(activity.folderUrl),
    '家長通知書:' + valueOrPending_(activity.parentNoticeUrl),
    '家長同意書:' + valueOrPending_(activity.parentConsentUrl),
    '處室通知草稿:' + valueOrPending_(activity.officeEmailUrl),
    '導師行前提醒:' + valueOrPending_(activity.teacherReminderUrl),
    '',
    CAMP_CONFIG.HUMAN_CONFIRMATION_NOTICE
  ].join('\n');

  MailApp.sendEmail(activity.ownerEmail, subject, body);
  recordNotificationStatus_(activity, '承辦人測試信', '承辦人', 'Email', new Date(), activity.ownerEmail, '已寄送', '已確認', '測試信只寄給承辦人');
  updateDashboard_();
  alert_('測試信已寄給承辦人:' + activity.ownerEmail);
}

function recordNotificationStatus_(activity, name, target, channel, date, fileLink, status, confirmation, note) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  sheet.appendRow([
    activity.id,
    name,
    target,
    channel,
    date,
    nowText_(),
    fileLink || '',
    status || '已記錄',
    confirmation || '待確認',
    note || ''
  ]);
}

function refreshNotificationDocumentLinks_(activity) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.NOTIFICATIONS);
  ensureHeaders_(sheet, CAMP_NOTIFICATION_HEADERS);
  const map = buildHeaderMap_(sheet);
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    if (String(readCellByHeader_(sheet, row, map, '活動ID')) !== String(activity.id)) continue;
    const name = String(readCellByHeader_(sheet, row, map, '通知名稱') || '');
    if (name.indexOf('家長活動通知') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentNoticeUrl);
    if (name.indexOf('家長同意書') >= 0 || name.indexOf('回收提醒') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.parentConsentUrl);
    if (name.indexOf('導師') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.teacherReminderUrl);
    if (name.indexOf('處室') >= 0 || name.indexOf('協調') >= 0) writeCellByHeader_(sheet, row, map, '文件連結', activity.officeEmailUrl);
  }
}

檔案 17

DeadlineService.gs|期限狀態標記

/**
 * 營隊/活動管理中樞:期限狀態與自動標記。
 */
function updateDeadlineStatuses() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const map = ensureHeaders_(sheet, CAMP_ACTIVITY_HEADERS);
  let updated = 0;

  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const activity = readActivityFromRow_(sheet, row, map);
    if (!activity.name) continue;
    const registrationStatus = calculateDateStatus_(activity.registrationDeadline, false);
    const consentStatus = calculateDateStatus_(activity.consentDeadline, false);
    const activityStatus = calculateDateStatus_(activity.activityDate, false);
    const status = buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus);
    writeCellByHeader_(sheet, row, map, '目前狀態', status);
    writeCellByHeader_(sheet, row, map, '最近更新時間', nowText_());
    updated++;
  }

  updateDashboard_();
  alert_('期限狀態已更新:' + updated + ' 筆活動。');
}

function buildActivityDateStatusText_(registrationStatus, consentStatus, activityStatus) {
  const urgent = [];
  if (registrationStatus === '逾期' || registrationStatus === '即將到期') urgent.push('報名' + registrationStatus);
  if (consentStatus === '逾期' || consentStatus === '即將到期') urgent.push('同意書' + consentStatus);
  if (activityStatus === '逾期') urgent.push('活動日期已過');
  if (activityStatus === '即將到期') urgent.push('活動即將到期');
  return urgent.length ? urgent.join(';') : '期限正常';
}

function buildDeadlineRows_(activity) {
  const activityDate = parseDate_(activity.activityDate);
  return [
    ['報名截止日', activity.registrationDeadline, '確認報名人數、未回覆名單與特殊需求'],
    ['同意書回收日', activity.consentDeadline, '確認同意書、保險與名冊'],
    ['活動前提醒日', addDays_(activityDate, -3), '確認導師、處室、家長與學生行前提醒'],
    ['成果整理日', addDays_(activityDate, 3), '整理照片、流程紀錄、回饋與附件'],
    ['NotebookLM 匯出日', addDays_(activityDate, 7), '去識別化後匯出資料包']
  ];
}

檔案 18

NotebookExport.gs|NotebookLM 資料包匯出

/**
 * 營隊/活動管理中樞:NotebookLM 資料包匯出。
 */
function exportNotebookLMPackageForSelectedActivity() {
  const packageContext = ensureActivityPackageForSelectedActivity_();
  const activity = packageContext.activity;
  const rowContext = packageContext.rowContext;
  const content = buildNotebookMarkdown_(activity);
  const doc = createNotebookDoc_(activity, content, packageContext.subfolders['07_NotebookLM資料包']);

  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, 'NotebookLM資料包連結', doc.getUrl());
  writeCellByHeader_(rowContext.sheet, rowContext.row, rowContext.headerMap, '最近更新時間', nowText_());
  appendDocumentListRow_(activity, 'NotebookLM資料包', doc.getName(), '匯入 NotebookLM 的去識別化專案摘要', doc.getUrl(), '系統匯出', '草稿', '待確認', '匯入前請再次檢查個資');
  updateDashboard_();
  alert_('NotebookLM 資料包已匯出:\n' + doc.getUrl());
  return doc.getUrl();
}

function buildNotebookMarkdown_(activity) {
  const ss = getActiveSpreadsheet_();
  const taskRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.TASKS), activity.id);
  const documentRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.DOCUMENTS), activity.id);
  const notificationRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.NOTIFICATIONS), activity.id);
  const outcomeRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.OUTCOMES), activity.id);
  const sessionRows = getRowsByActivityId_(ss.getSheetByName(CAMP_SHEETS.SESSIONS), activity.id);
  const registrationStats = getRegistrationStatsByActivity_(ss.getSheetByName(CAMP_SHEETS.REGISTRATIONS))[activity.id] || { total: 0, join: 0, notJoin: 0, special: 0 };

  return [
    '# NotebookLM匯入用|活動行政專案資料包',
    '',
    '## 一、活動基本資料',
    activityToText_(activity),
    '',
    '## 二、報名統計',
    '- 報名總人數:' + registrationStats.total,
    '- 參加人數:' + registrationStats.join,
    '- 不參加人數:' + registrationStats.notJoin,
    '- 特殊需求筆數:' + registrationStats.special,
    '- 注意:此處只放統計,不放學生電話、健康細節或身分資料。',
    '',
    '## 三、場次總覽',
    formatRowsForNotebook_(sessionRows, CAMP_SESSION_HEADERS, ['場次ID', '場次日期', '開始時間', '結束時間', '場次名稱', '場次主題', '講師', '地點或線上連結', '目前狀態', '報名人數', '簽到人數', '出席率']),
    '',
    '## 四、逐場任務狀態',
    formatSessionTaskSummary_(taskRows),
    '',
    '## 五、逐場成果整理',
    formatSessionOutcomeSummary_(outcomeRows),
    '',
    '## 六、多日活動行政觀察',
    '- 請檢查哪些場次報名人數偏低、簽到率偏低或課前任務未完成。',
    '- 請確認高風險場次是否已補上場地、講義、簽到、回饋與成果整理。',
    '- 若後續場次會受前一場成果影響,請在任務追蹤表補上前置任務。',
    '',
    '## 七、重要期限',
    buildDeadlineRows_(activity).map(function(row) {
      return '- ' + row[0] + ':' + formatDateValue_(row[1]) + ',' + row[2];
    }).join('\n'),
    '',
    '## 八、任務執行情形',
    formatRowsForNotebook_(taskRows, CAMP_TASK_HEADERS, ['階段', '任務名稱', '負責人', '建議完成日期', '狀態', '優先程度']),
    '',
    '## 九、通知紀錄',
    formatRowsForNotebook_(notificationRows, CAMP_NOTIFICATION_HEADERS, ['通知名稱', '通知對象', '通知管道', '建議通知日期', '寄送或通知狀態', '承辦人確認']),
    '',
    '## 十、文件清單',
    formatRowsForNotebook_(documentRows, CAMP_DOCUMENT_HEADERS, ['文件類型', '文件名稱', '用途', '連結', '狀態', '承辦人確認']),
    '',
    '## 十一、成果與檢查',
    formatRowsForNotebook_(outcomeRows, CAMP_OUTCOME_HEADERS, ['成果項目', '是否完成', '是否含個資', '是否可提供 NotebookLM', '負責人', '備註']),
    '',
    '## 十二、個資與匯出提醒',
    '- 不匯出學生電話、家長電話、健康細節、身分資料、完整名冊。',
    '- 不匯出完整簽到名冊、個別缺席原因或個別健康需求。',
    '- 需要使用學生回饋時,請先去識別化。',
    '- 正式公告、收費、保險、交通、安全與核銷仍由承辦人確認。',
    '',
    '## 十三、可問 NotebookLM 的問題範例',
    '1. 這個活動目前最需要先確認哪些事項?',
    '2. 哪些任務還沒有完成?',
    '3. 家長通知或導師提醒有哪些缺漏?',
    '4. 成果報告還缺哪些素材?',
    '5. 下次辦理有哪些可沿用與需調整事項?',
    '',
    '## 十四、AI 提示詞',
    CAMP_PROMPTS.notebook.replace('{{WORKFLOW_DATA}}', activityToText_(activity))
  ].join('\n');
}

function formatSessionTaskSummary_(taskRows) {
  if (!taskRows.length) return '目前尚無逐場任務。';
  const grouped = {};
  taskRows.forEach(function(row) {
    const sessionId = row[CAMP_TASK_HEADERS.indexOf('場次ID')] || 'ALL';
    if (!grouped[sessionId]) grouped[sessionId] = [];
    grouped[sessionId].push(row);
  });
  return Object.keys(grouped).sort().map(function(sessionId) {
    const rows = grouped[sessionId];
    const done = rows.filter(function(row) {
      return row[CAMP_TASK_HEADERS.indexOf('狀態')] === '已完成';
    }).length;
    return '- ' + sessionId + ':任務 ' + rows.length + ' 筆,已完成 ' + done + ' 筆,完成率 ' + formatPercent_(done, rows.length);
  }).join('\n');
}

function formatSessionOutcomeSummary_(outcomeRows) {
  if (!outcomeRows.length) return '目前尚無逐場成果資料。';
  const itemIndex = CAMP_OUTCOME_HEADERS.indexOf('成果項目');
  const statusIndex = CAMP_OUTCOME_HEADERS.indexOf('是否完成');
  return outcomeRows.filter(function(row) {
    return /^S\d{2}/.test(String(row[itemIndex] || ''));
  }).map(function(row) {
    return '- ' + row[itemIndex] + ':' + valueOrPending_(row[statusIndex]);
  }).join('\n') || '目前尚無逐場成果資料。';
}

function createNotebookDoc_(activity, content, folder) {
  const doc = DocumentApp.create(activity.id + '_NotebookLM資料包_' + sanitizeFileName_(activity.name));
  const body = doc.getBody();
  content.split('\n').forEach(function(line) {
    if (line.indexOf('# ') === 0) {
      body.appendParagraph(line.replace(/^# /, '')).setHeading(DocumentApp.ParagraphHeading.TITLE);
    } else if (line.indexOf('## ') === 0) {
      body.appendParagraph(line.replace(/^## /, '')).setHeading(DocumentApp.ParagraphHeading.HEADING1);
    } else {
      body.appendParagraph(line);
    }
  });
  doc.saveAndClose();
  moveFileToFolder_(doc.getId(), folder);
  return doc;
}

function formatRowsForNotebook_(rows, headers, selectedHeaders) {
  if (!rows.length) return '目前尚無資料。';
  return rows.map(function(row) {
    return '- ' + selectedHeaders.map(function(header) {
      const index = headers.indexOf(header);
      const value = index >= 0 ? row[index] : '';
      return header + ':' + valueOrPending_(formatDateIfNeeded_(value));
    }).join(';');
  }).join('\n');
}

function formatDateIfNeeded_(value) {
  if (value instanceof Date) return formatDateValue_(value);
  return value;
}

檔案 19

AiPrompts.gs|AI 提示詞模板

/**
 * 營隊/活動管理中樞:提示詞集中管理。
 *
 * MVP 先採用「提示詞模板」。Gemini API 可於進階階段再接上。
 */
const CAMP_PROMPTS = {
  activityDiagnosis: [
    '你是一位熟悉台灣國小行政流程的 AI 行政專案顧問。',
    '請根據以下活動建案資料,協助判斷這個活動是否適合用活動型行政專案方式管理。',
    '請用表格回答:',
    '1. 目前資訊已清楚的部分',
    '2. 目前資訊不足的部分',
    '3. 最優先要確認的 5 件事',
    '4. 如果直接執行,可能出現的行政風險',
    '5. 建議建立哪些 Google Workspace 文件、表單、資料夾或提醒',
    '',
    '活動資料:',
    '{{ACTIVITY_DATA}}'
  ].join('\n'),
  parentNotice: [
    '你是一位熟悉台灣國小行政語氣的學校行政助理。',
    '請根據以下活動資料,產出一份給家長的活動通知書草稿。',
    '請注意:',
    '1. 語氣正式、清楚、親切。',
    '2. 日期、費用、集合時間若資料不足,請用【待確認】標示。',
    '3. 涉及收費、保險、交通、安全者,請提醒承辦人正式確認。',
    '4. 不要自行編造不存在的資料。',
    '5. 使用繁體中文。',
    '6. 最後加上「承辦人確認事項」。',
    '',
    '活動資料:',
    '{{ACTIVITY_DATA}}'
  ].join('\n'),
  riskCheck: [
    '你是一位協助學校行政人員進行活動風險盤點的 AI 助理。',
    '請根據活動資料、任務、通知與成果檢查,找出可能風險。',
    '請分成時程、人員、學生安全、交通、家長溝通、經費核銷、成果結案、個資與資料安全。',
    '每一項請列出風險內容、可能造成的問題、預防方式、是否需要提醒、承辦人需確認事項。',
    '',
    '活動資料:',
    '{{ACTIVITY_DATA}}'
  ].join('\n'),
  notebook: [
    '你是一位協助學校行政人員整理 NotebookLM 專案資料包的助理。',
    '請根據以下活動資料與工作流摘要,整理成可匯入 NotebookLM 的乾淨資料包。',
    '請不要放入學生電話、健康細節、身分資料或不必要個資。',
    '請包含:活動基本資料、重要時程、任務狀態、通知紀錄、成果檢查、下次辦理建議、可問 NotebookLM 的問題。',
    '',
    '工作流資料:',
    '{{WORKFLOW_DATA}}'
  ].join('\n')
};

檔案 20

SampleData.gs|新北AI研習示範資料

/**
 * 營隊/活動管理中樞:研習示範資料。
 */
function addNewTaipeiAiWorkshopSample() {
  const ss = getActiveSpreadsheet_();
  initializeSheets_(ss);

  const activityId = 'ACT-NTPC-AI-11506';
  const activityName = '新北AI共創大聯盟|Google Workspace 行政專案工作流自動化實作';
  const activitySheet = getOrCreateSheet_(ss, CAMP_SHEETS.ACTIVITIES);
  const activityMap = ensureHeaders_(activitySheet, CAMP_ACTIVITY_HEADERS);
  const existingRow = findActivityRowByIdOrName_(activitySheet, activityMap, activityId, activityName);

  if (existingRow) {
    const sessionSheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
    ensureHeaders_(sessionSheet, CAMP_SESSION_HEADERS);
    if (getRowsByActivityId_(sessionSheet, activityId).length === 0) {
      appendNewTaipeiAiWorkshopSessions_(activityId);
      updateDashboard_();
      alert_('示範活動已存在,已補上缺漏的 4 筆場次設定。\n\n活動ID:' + activityId);
      return;
    }
    alert_('示範資料已存在,不重複新增。\n\n活動ID:' + activityId);
    return;
  }

  const startDate = new Date(2026, 5, 15);
  const endDate = new Date(2026, 5, 25);
  const registrationDeadline = new Date(2026, 5, 15);
  const activity = {};
  activity['活動ID'] = activityId;
  activity['活動名稱'] = activityName;
  activity['活動類型'] = '研習';
  activity['報名表模板類型'] = '線上研習';
  activity['活動日期'] = startDate;
  activity['活動開始日'] = startDate;
  activity['活動結束日'] = endDate;
  activity['每日預設開始時間'] = '19:00';
  activity['每日預設結束時間'] = '21:00';
  activity['活動天數'] = 4;
  activity['場次數'] = 4;
  activity['是否多日活動'] = '是';
  activity['是否開放部分場次報名'] = '是';
  activity['是否需要每日簽到'] = '是';
  activity['是否需要課後回饋'] = '是';
  activity['是否需要成果繳交'] = '否';
  activity['報名截止日'] = registrationDeadline;
  activity['同意書回收日'] = '';
  activity['活動地點'] = 'Google Meet 線上研習:https://meet.google.com/usb-mjie-ett';
  activity['參加對象'] = '新北市各校教師及行政人員';
  activity['預估人數'] = '';
  activity['承辦處室'] = '新北市政府教育局/新北AI共創大聯盟';
  activity['承辦人'] = '彭智標主任';
  activity['承辦人Email'] = Session.getActiveUser().getEmail() || '';
  activity['是否收費'] = '否';
  activity['費用說明'] = '線上研習,未載明收費';
  activity['交通方式'] = '線上研習無交通';
  activity['是否需要保險'] = '否';
  activity['是否需要家長同意書'] = '否';
  activity['報名表單連結'] = 'https://forms.gle/heMoe3KLF97RB6Hy5';
  activity['報名人數'] = 0;
  activity['同意參加'] = 0;
  activity['不參加'] = 0;
  activity['特殊需求數'] = 0;
  activity['目前狀態'] = '示範資料';
  activity['最近更新時間'] = nowText_();
  activity['備註'] = [
    '示範資料:新北AI研習。',
    '本筆資料已直接寫入活動清單與場次設定。',
    '若使用活動新增表單同步功能,系統應略過同名活動,避免重複建立。',
    '',
    '公文:新北教技字第1151036018號。',
    '研習名稱:「新北AI共創大聯盟」人工智慧增能研習—Google Workspace 行政專案工作流自動化實作。',
    '課程綱要:https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
    '研習主軸:從公文到成果,建立半自動行政專案管理工作流。',
    '正式公告、研習時數、簽到與行政作業仍依主辦單位規定辦理。'
  ].join('\n');

  activitySheet.appendRow(CAMP_ACTIVITY_HEADERS.map(function(header) {
    return activity[header] || '';
  }));

  appendNewTaipeiAiWorkshopSessions_(activityId);
  updateDashboard_();

  alert_([
    '示範研習資料已新增完成。',
    '',
    '活動:' + activityName,
    '活動ID:' + activityId,
    '',
    '已新增 1 筆活動資料與 4 筆場次設定。',
    '請到「01_活動清單」選取這筆活動,再執行「從選取列建立活動資料包」。'
  ].join('\n'));
}

function appendNewTaipeiAiWorkshopSessions_(activityId) {
  const ss = getActiveSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, CAMP_SHEETS.SESSIONS);
  ensureHeaders_(sheet, CAMP_SESSION_HEADERS);

  if (getRowsByActivityId_(sheet, activityId).length > 0) {
    return;
  }

  const sessions = [
    {
      sessionId: 'S01',
      date: new Date(2026, 5, 15),
      title: '第一場|從公文啟動專案',
      topic: '以教育局公文導入教學研習計畫為情境,學習判斷公文是否值得建立專案,並建立專案資料夾、追蹤表與成果檢核文件。'
    },
    {
      sessionId: 'S02',
      date: new Date(2026, 5, 18),
      title: '第二場|活動型專案管理',
      topic: '以校外教學/親子活動辦理為案例,拆解活動流程、建立階段性日期、設計報名表與回饋表。'
    },
    {
      sessionId: 'S03',
      date: new Date(2026, 5, 22),
      title: '第三場|成果型專案與AI文件整理',
      topic: '以補助計畫/成果報告為案例,建立 NotebookLM 專案筆記本,並運用 Gemini 輔助公告、摘要與成果報告草稿。'
    },
    {
      sessionId: 'S04',
      date: new Date(2026, 5, 25),
      title: '第四場|大型專案協作與制度化',
      topic: '以運動會/校慶大型活動為案例,學習跨處室分工、大量日期與任務管理、成果檢核與制度化。'
    }
  ];

  const rows = sessions.map(function(session) {
    return buildSessionSheetRow_({
      '活動ID': activityId,
      '場次ID': session.sessionId,
      '場次日期': session.date,
      '開始時間': '19:00',
      '結束時間': '21:00',
      '場次名稱': session.title,
      '場次主題': session.topic,
      '場次目標': 'Google Workspace、Apps Script、NotebookLM、Gemini 行政工作流實作',
      '講師': '彭智標主任',
      '助教或協作人員': '新北市立泰山高中協助簽到與研習時數行政作業',
      '地點或線上連結': 'Google Meet:https://meet.google.com/usb-mjie-ett',
      '本場需要設備': 'Google 帳號、可操作 Google Workspace 的電腦、近期實際行政專案案例',
      '講義連結': 'https://mihozip.tw/teacherbiao/lectures/geminixnotebooklm/course_plan_admin_project_workflow.html',
      '報名人數': 0,
      '簽到人數': 0,
      '出席率': '0%',
      '課前任務完成率': '0%',
      '課後回饋數': 0,
      '成果整理狀態': '未整理',
      '風險等級': '低',
      '目前狀態': '準備中',
      '備註': '由新北研習公文建立之示範場次',
      '任務完成率': '0%',
      '任務完成狀態': '未開始'
    });
  });

  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, CAMP_SESSION_HEADERS.length).setValues(rows);
}

function findActivityRowByIdOrName_(sheet, headerMap, activityId, activityName) {
  for (let row = 2; row <= sheet.getLastRow(); row++) {
    const existingId = String(readCellByHeader_(sheet, row, headerMap, '活動ID') || '').trim();
    const existingName = String(readCellByHeader_(sheet, row, headerMap, '活動名稱') || '').trim();
    if (existingId === activityId || existingName === activityName) {
      return row;
    }
  }
  return 0;
}

七、驗收清單