SRDI · YRU สวพ.สัญจร 2570

ระบบขับเคลื่อนและติดตามงาน กิจกรรม สวพ.สัญจร ปีงบประมาณ พ.ศ. 2570

สถาบันวิจัยและพัฒนาชายแดนภาคใต้ มหาวิทยาลัยราชภัฏยะลา | ฐานข้อมูลกลาง: Google Sheet + GAS API

👁️ โหมดผู้ชมทั่วไป (ดูได้อย่างเดียว)
📌 สถานะการเข้าใช้งาน: ขณะนี้คุณอยู่ใน โหมดผู้ชมทั่วไป (View-Only) สามารถดูแดชบอร์ด ปฏิทิน ตารางนัดหมาย และคลิกเปิดโฟลเดอร์หลักฐานได้ทั้งหมด หากต้องการบันทึกผลหรือปรับวันนัดหมาย กรุณากดปุ่ม "🔒 ปลดล็อกโหมดบันทึกผล (PIN)" ด้านบน
📊 เปิด Google Sheet ต้นฉบับ 📄 เปิดใบมอบหมายงาน PDF 📂 เปิดโฟลเดอร์หลักโครงการ
งานหลักทั้งหมด
20
ครอบคลุมครบ 7 หมวดงานตามใบมอบหมาย
ดำเนินการเสร็จแล้ว
1
คิดเป็นความคืบหน้า 5.0%
กำลังทำ / รอตรวจ
1
กำลังดำเนินการและรอหัวหน้าโครงการตรวจ
สถานะนัดหมาย 6 คณะ
6 คณะ
ตามกำหนดเดิม 6 คณะ | เลื่อนวัน 0 คณะ
คำถาม/เรื่องฝากจากคณะ
1
ติดตามใน Issue & Commitment Log
📈 ความคืบหน้าแยกตามทีมผู้รับผิดชอบ (7 หมวดงาน)
คำนวณแบบ Real-time จากสถานะงานใน Google Sheet
🗓️ สรุปคิวลงพื้นที่ 6 คณะ & จุดเคาะไฟล์ (Freeze D-1)
ปรับเปลี่ยนอัตโนมัติทันทีหากรัตนาอัปเดตวันนัดหมายใหม่
📂 โครงสร้างโฟลเดอร์แนบหลักฐานกลาง (คลิกเพื่อเปิดและอัปโหลดไฟล์)
จัดระเบียบเรียงตามหมวดงาน 01–08 และตัดวันที่ออกจากชื่อโฟลเดอร์คณะเพื่อรองรับการเลื่อนวัน
📅 ปฏิทินปฏิบัติการ สวพ.สัญจร 2570 (ตุลาคม – พฤศจิกายน 2569)
แสดงวันลงพื้นที่จริงของทั้ง 6 คณะ, วันส่งร่างสไลด์/ตรวจห้อง (D-3), วัน Freeze สื่อ (D-1) และวันปิดโครงการ (ขยับตามการเลื่อนวันอัตโนมัติ)
🔵 วันจัดกิจกรรมคณะ 🟠 คณะที่มีการเลื่อนวัน 🟣 วัน Freeze สื่อ (D-1) 🟢 วันสรุปปิดโครงการ
🔄 ตารางควบคุมวันนัดหมายยืดหยุ่น (สำหรับรัตนา) & คำนวณวันกระทบทุกทีมอัตโนมัติ
เมื่อปลดล็อก PIN แล้ว รัตนาเปลี่ยน "วันจัดจริง" เพียงช่องเดียว ระบบจะคำนวณวันส่งงานใหม่ของ อาวาติฟ, นูรีซัน, กันตกาล, ซุรียาณี และมาดีฮะห์ ทันที!
รหัส คณะที่ลงพื้นที่ วันจัดเดิม ✏️ สถานะนัดหมาย (รัตนา) ✏️ วันจัดจริง (ถ้าเลื่อนแก้วันนี้) ✏️ เวลา / ห้องประชุม / หมายเหตุ 🔔 แจ้งเตือนผลกระทบ 🔄 อาวาติฟ/นูรีซัน
ร่างคิว & PR (D-5)
🔄 รัตนา/อาวาติฟ
ตรวจห้อง & สไลด์ (D-3)
🔄 ทุกทีม
Freeze & เตือน (D-1)
🔄 ซุรียาณี
สรุป 1 หน้า (D+1)
🔄 นูรีซัน/มาดีฮะห์
ข่าว & ประเมิน (D+2)
บันทึก
📋 ตารางเช็คความพร้อมรายคณะ (6 คณะ) & โฟลเดอร์ส่งไฟล์ประจำคณะ
แต่ละทีมกดเลือกสถานะความพร้อมของคณะนั้นๆ และคลิกเปิดโฟลเดอร์ประจำคณะเพื่อวางไฟล์ได้ทันที
รหัสคณะ คณะเป้าหมาย วันจัดจริง 📂 โฟลเดอร์ประจำคณะ ✏️ [รัตนา]
นัดหมาย/ห้อง
✏️ [อาวาติฟ]
สไลด์/คิวงาน
✏️ [นูรีซัน]
แจ้งเตือน/ข่าว
✏️ [ซุรียาณี]
สรุป 1 หน้า
✏️ [มาดีฮะห์]
QR/ประเมิน
✏️ [กันตกาล]
Freeze/รับรอง
✏️ หมายเหตุ/ชื่อไฟล์ที่ส่งแล้ว บันทึก
✅ ตารางบันทึกงานประจำทีม (ครบ 7 หมวดงานตามใบมอบหมายงาน)
คลิกชื่อตัวเองเพื่อกรองเฉพาะงานที่รับผิดชอบ ➜ คลิกเปิดโฟลเดอร์เพื่อวางไฟล์ ➜ อัปเดตสถานะและกดบันทึก
👤 กรองตามชื่อผู้รับผิดชอบ:
รหัส ผู้รับผิดชอบ งานที่ต้องทำ และสิ่งที่ต้องส่ง (ภาษาง่ายๆ) กำหนดส่ง 📂 โฟลเดอร์เก็บหลักฐาน ✏️ [ช่อง 1] สถานะ ✏️ [ช่อง 2] บันทึกผล / ชื่อไฟล์ที่วางแล้ว ✏️ [ช่อง 3] ปัญหา / การปรับแผน บันทึก
💬 สมุดจดคำถามและเรื่องฝากตามต่อจากคณะ (Issue & Commitment Log)
กฎเหล็กตามใบมอบหมายงาน: เรื่องไหนที่ยังตอบไม่ได้ในเวที ห้ามตอบเดา! ให้จดลงตารางนี้เพื่อตามเจ้าของภารกิจมาตอบ และต้องมีหลักฐานก่อนปิดเรื่อง
ลำดับ ✏️ คณะที่ถาม ✏️ คำถาม / ปัญหา / เรื่องที่คณะฝากให้ช่วยตามต่อ ✏️ ผู้รับผิดชอบหาคำตอบ ✏️ กำหนดส่งคำตอบ ✏️ สถานะ ✏️ สรุปคำตอบที่ส่งแล้ว & ชื่อไฟล์หลักฐาน บันทึก
🏗️ สถาปัตยกรรมระบบ: Google Sheet + Google Apps Script (GAS) + GitHub + Cloudflare Pages
ออกแบบให้เชื่อมข้อมูลสองทาง (Two-Way Sync) ระหว่าง Google Sheet และหน้าเว็บ พร้อมระบบล็อกสิทธิ์ด้วย PIN: GA123555

1. ฐานข้อมูล (Google Sheet)

ใช้ไฟล์ สมุดบันทึกงาน_สวพ.สัญจร_2570_ฉบับใช้ง่าย เดิมเป็นฐานข้อมูลกลาง แก้ไขจากใน Sheet หรือแก้ผ่านหน้าเว็บ ข้อมูลจะอัปเดตตรงกันเสมอ

2. หลังบ้าน (Google Apps Script)

ทำหน้าที่เป็น API (doGet ดึงข้อมูลสาธารณะ / doPost ตรวจสอบรหัส PIN: GA123555 ก่อนอนุญาตให้บันทึกข้อมูลลง Sheet)

3. โค้ดหน้าบ้าน (GitHub Repo)

เก็บไฟล์ index.html ชุดนี้ไว้บน GitHub เพื่อควบคุมเวอร์ชันและแก้ไขข้อความ/ดีไซน์ได้สะดวก

4. ระบบเผยแพร่ (Cloudflare Pages)

เชื่อมกับ GitHub เพื่อเปิดเว็บให้ทีมงานและผู้บริหารเข้าใช้งานผ่าน URL ความเร็วสูง รองรับทั้งมือถือและคอมพิวเตอร์ฟรี

ขั้นตอนการติดตั้งใช้งานจริง (ทำครั้งเดียว ไม่เกิน 5 นาที)

  1. ติดตั้งโค้ดหลังบ้าน (GAS): เปิดไฟล์ สมุดบันทึกงาน_สวพ.สัญจร_2570_ฉบับใช้ง่าย ➜ ไปที่เมนู ส่วนขยาย (Extensions) > Apps Script ➜ ลบโค้ดเดิมแล้ววางโค้ด Code.gs ด้านล่างลงไป
  2. Deploy เป็น Web App: ในหน้า Apps Script กดปุ่ม การทำให้ใช้งานได้ (Deploy) > การทำให้ใช้งานได้รายการใหม่ (New deployment) ➜ เลือกประเภท เว็บแอป (Web app) ➜ ตั้งค่า ดำเนินการในฐานะ (Execute as): ฉัน (Me) และ ผู้ที่มีสิทธิ์เข้าถึง (Who has access): ทุกคน (Anyone) ➜ กด Deploy แล้วคัดลอก Web App URL (ที่ลงท้ายด้วย /exec)
  3. เชื่อม Web App URL เข้ากับหน้าเว็บ: กดปุ่ม "⚙️ ตั้งค่าลิงก์ GAS" บนแถบด้านบนของเว็บนี้ แล้ววาง Web App URL (หรือใส่ไว้ในตัวแปร DEFAULT_GAS_URL ในไฟล์ index.html ก่อนอัปโหลดขึ้น GitHub + Cloudflare Pages)
📄 โค้ดหลังบ้าน Google Apps Script (Code.gs) — ผูกกับ Sheet ID และรหัส PIN: GA123555 เรียบร้อยแล้ว
/**
 * ============================================================================
 * ระบบ Backend API (Google Apps Script) สำหรับกิจกรรม สวพ.สัญจร ปีงบประมาณ พ.ศ. 2570
 * สถาบันวิจัยและพัฒนาชายแดนภาคใต้ (สวพ.ชต. / SRDI) มหาวิทยาลัยราชภัฏยะลา
 * เชื่อมต่อกับ Google Sheet: สมุดบันทึกงาน_สวพ.สัญจร_2570_ฉบับใช้ง่าย
 * ============================================================================
 */

const SPREADSHEET_ID = '1MiLun3SHJtD5RJmuD5bsmKeLtLKQWLq9woORMsD6Ynw';
const EDIT_PIN = 'GA123555';

const SHEET_TASKS = '1_บันทึกงานประจำทีม (ใช้ง่าย)';
const SHEET_SCHEDULE = '2_ตารางนัดหมายยืดหยุ่น_6คณะ';
const SHEET_ISSUES = '3_สมุดจดคำถามจากคณะ';

/**
 * GET API: ดึงข้อมูลทั้งหมดจาก Google Sheet ไปแสดงผลบน Frontend (ทุกคนดูได้ ไม่ต้องใช้ PIN)
 */
function doGet(e) {
  try {
    const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
    const data = {
      status: 'success',
      updatedAt: new Date().toISOString(),
      spreadsheetUrl: ss.getUrl(),
      tasks: readTasksSheet_(ss.getSheetByName(SHEET_TASKS)),
      schedule: readScheduleSheet_(ss.getSheetByName(SHEET_SCHEDULE)),
      issues: readIssuesSheet_(ss.getSheetByName(SHEET_ISSUES))
    };
    return ContentService.createTextOutput(JSON.stringify(data))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (err) {
    return ContentService.createTextOutput(JSON.stringify({
      status: 'error',
      message: err.toString()
    })).setMimeType(ContentService.MimeType.JSON);
  }
}

/**
 * POST API: บันทึก/แก้ไขข้อมูลจาก Frontend ลง Google Sheet (ต้องมี PIN: GA123555 เท่านั้น)
 */
function doPost(e) {
  const lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    const payload = JSON.parse(e.postData.contents || '{}');

    // ตรวจสอบรหัส PIN ที่ฝั่ง Server ทุกครั้ง
    if (String(payload.pin || '').trim() !== EDIT_PIN) {
      return jsonResponse_({
        status: 'error',
        code: 'INVALID_PIN',
        message: 'รหัส PIN ไม่ถูกต้อง ไม่อนุญาตให้บันทึกข้อมูล (ต้องใช้รหัส PIN สำหรับทีมงานเท่านั้น)'
      });
    }

    const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
    const action = payload.action;

    // 1) อัปเดตงานประจำทีม (แท็บที่ 1: แถวที่ 8-27, คอลัมน์ F, G, H)
    if (action === 'updateTask') {
      const sh = ss.getSheetByName(SHEET_TASKS);
      const values = sh.getRange(8, 1, 20, 8).getValues();
      let foundRow = -1;
      for (let i = 0; i < values.length; i++) {
        if (String(values[i][0]).trim() === String(payload.taskId).trim()) {
          foundRow = i + 8;
          break;
        }
      }
      if (foundRow === -1) throw new Error('ไม่พบรหัสงาน: ' + payload.taskId);
      sh.getRange(foundRow, 6).setValue(payload.status || 'ยังไม่เริ่ม');
      sh.getRange(foundRow, 7).setValue(payload.note || '');
      sh.getRange(foundRow, 8).setValue(payload.problem || '');
    }

    // 2) อัปเดตวันนัดหมายยืดหยุ่นของรัตนา & สถานะรายคณะ (แท็บที่ 2)
    else if (action === 'updateSchedule') {
      const sh = ss.getSheetByName(SHEET_SCHEDULE);
      // ตารางบน: แถว 6-11 (คอลัมน์ D=สถานะนัด, E=วันจัดจริง, F=เวลา/ห้อง/หมายเหตุ)
      const topVals = sh.getRange(6, 1, 6, 6).getValues();
      let topRow = -1;
      for (let i = 0; i < topVals.length; i++) {
        if (String(topVals[i][0]).trim() === String(payload.facultyCode).trim()) {
          topRow = i + 6;
          break;
        }
      }
      if (topRow === -1) throw new Error('ไม่พบรหัสคณะ: ' + payload.facultyCode);
      if (payload.coordStatus !== undefined) sh.getRange(topRow, 4).setValue(payload.coordStatus);
      if (payload.actualDate !== undefined) sh.getRange(topRow, 5).setValue(payload.actualDate);
      if (payload.roomNote !== undefined) sh.getRange(topRow, 6).setValue(payload.roomNote);

      // ตารางล่าง: แถว 16-21 (คอลัมน์ E ถึง K = สถานะรายทีมของคณะนั้น)
      const botRow = topRow + 10;
      if (payload.teamStatuses) {
        const ts = payload.teamStatuses;
        if (ts.rattana !== undefined) sh.getRange(botRow, 5).setValue(ts.rattana);
        if (ts.awatif !== undefined) sh.getRange(botRow, 6).setValue(ts.awatif);
        if (ts.nurisan !== undefined) sh.getRange(botRow, 7).setValue(ts.nurisan);
        if (ts.suriyanee !== undefined) sh.getRange(botRow, 8).setValue(ts.suriyanee);
        if (ts.madeehah !== undefined) sh.getRange(botRow, 9).setValue(ts.madeehah);
        if (ts.kantakan !== undefined) sh.getRange(botRow, 10).setValue(ts.kantakan);
        if (ts.filesNote !== undefined) sh.getRange(botRow, 11).setValue(ts.filesNote);
      }
    }

    // 3) เพิ่มหรืออัปเดตคำถามจากคณะ (แท็บที่ 3: Issue & Commitment Log)
    else if (action === 'saveIssue') {
      const sh = ss.getSheetByName(SHEET_ISSUES);
      if (payload.rowIndex && Number(payload.rowIndex) >= 5) {
        const r = Number(payload.rowIndex);
        sh.getRange(r, 2, 1, 6).setValues([[
          payload.faculty || '',
          payload.question || '',
          payload.owner || '',
          payload.dueDate || '',
          payload.status || 'กำลังดำเนินการ',
          payload.answer || ''
        ]]);
      } else {
        // หาแถวว่างแรกตั้งแต่แถวที่ 6 หรือเพิ่มแถวต่อท้าย
        const allVals = sh.getRange(5, 1, 30, 7).getValues();
        let targetRow = -1;
        let nextSeq = 1;
        for (let i = 1; i < allVals.length; i++) {
          const qText = String(allVals[i][2] || '').trim();
          if (!qText) {
            targetRow = i + 5;
            nextSeq = allVals[i][0] || i;
            break;
          }
        }
        if (targetRow === -1) {
          targetRow = sh.getLastRow() + 1;
          nextSeq = targetRow - 4;
        }
        sh.getRange(targetRow, 1, 1, 7).setValues([[
          String(nextSeq),
          payload.faculty || '',
          payload.question || '',
          payload.owner || '',
          payload.dueDate || '',
          payload.status || 'กำลังดำเนินการ',
          payload.answer || ''
        ]]);
      }
    } else {
      throw new Error('ไม่รู้จักคำสั่ง action: ' + action);
    }

    SpreadsheetApp.flush();
    return jsonResponse_({
      status: 'success',
      message: 'บันทึกข้อมูลลง Google Sheet เรียบร้อยแล้ว',
      updatedAt: new Date().toISOString()
    });
  } catch (err) {
    return jsonResponse_({
      status: 'error',
      message: err.toString()
    });
  } finally {
    lock.releaseLock();
  }
}

function readTasksSheet_(sh) {
  if (!sh) return [];
  const vals = sh.getRange(8, 1, 20, 8).getDisplayValues();
  const formulas = sh.getRange(8, 5, 20, 1).getFormulas();
  return vals.filter(r => r[0]).map((r, idx) => {
    const f = formulas[idx][0] || '';
    const urlMatch = f.match(/HYPERLINK\("([^"]+)"/i);
    return {
      id: r[0],
      owner: r[1],
      title: r[2],
      dueText: r[3],
      folderLabel: r[4],
      folderUrl: urlMatch ? urlMatch[1] : 'https://drive.google.com/drive/folders/1dwr0i_9ub6-qGyMkw_0caRrzjdT8aVkL',
      status: r[5] || 'ยังไม่เริ่ม',
      note: r[6] || '',
      problem: r[7] || ''
    };
  });
}

function readScheduleSheet_(sh) {
  if (!sh) return [];
  const top = sh.getRange(6, 1, 6, 12).getDisplayValues();
  const bot = sh.getRange(16, 1, 6, 12).getDisplayValues();
  const botFormulas = sh.getRange(16, 4, 6, 1).getFormulas();
  return top.map((r, i) => {
    const b = bot[i] || [];
    const bf = (botFormulas[i] && botFormulas[i][0]) || '';
    const urlMatch = bf.match(/HYPERLINK\("([^"]+)"/i);
    return {
      facultyCode: r[0],
      facultyName: r[1],
      planDate: r[2],
      coordStatus: r[3],
      actualDate: r[4],
      roomNote: r[5],
      impactAlert: r[6],
      dMinus5: r[7],
      dMinus3: r[8],
      dMinus1: r[9],
      dPlus1: r[10],
      dPlus2: r[11],
      folderUrl: urlMatch ? urlMatch[1] : 'https://drive.google.com/drive/folders/1jlZftdw28M_nmU8aEK7dHQuY49uw-5T1',
      teamStatuses: {
        rattana: b[4] || 'ยังไม่เริ่ม',
        awatif: b[5] || 'ยังไม่เริ่ม',
        nurisan: b[6] || 'ยังไม่เริ่ม',
        suriyanee: b[7] || 'ยังไม่เริ่ม',
        madeehah: b[8] || 'ยังไม่เริ่ม',
        kantakan: b[9] || 'ยังไม่เริ่ม',
        filesNote: b[10] || ''
      }
    };
  });
}

function readIssuesSheet_(sh) {
  if (!sh) return [];
  const lastRow = Math.max(sh.getLastRow(), 5);
  const vals = sh.getRange(5, 1, lastRow - 4, 7).getDisplayValues();
  const result = [];
  vals.forEach((r, idx) => {
    if (String(r[1] || '').trim() || String(r[2] || '').trim()) {
      result.push({
        rowIndex: idx + 5,
        seq: r[0],
        faculty: r[1],
        question: r[2],
        owner: r[3],
        dueDate: r[4],
        status: r[5] || 'ยังไม่เริ่ม',
        answer: r[6] || ''
      });
    }
  });
  return result;
}

function jsonResponse_(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}