← บทความทั้งหมด

แจกฟรี พร้อมวิธีการติดตั้ง! KruBoard — กระดานส่งการบ้าน

AI & เครื่องมือ 📅 24 มิ.ย. 2569 ✍️ kruti
แจกฟรี พร้อมวิธีการติดตั้ง! KruBoard — กระดานส่งการบ้าน
🎁 แจกฟรี · ระบบส่งการบ้านออนไลน์

KruBoard — กระดานส่งการบ้าน
ที่ครูสร้างเองได้ ฟรี 100%

นักเรียนถ่ายรูปงานส่งผ่านมือถือ ครูตรวจให้คะแนนเห็นทั้งห้องในจอเดียว — ทำงานบน Google ทั้งหมด ข้อมูลอยู่ในบัญชีครูเอง ไม่มีค่าใช้จ่าย ไม่ต้องมีเซิร์ฟเวอร์ บทความนี้แจกโค้ดเต็ม พร้อมวิธีติดตั้งทีละขั้น

⏱️
ติดตั้ง 10 นาที
ทำครั้งเดียว ใช้ได้ทั้งปี
💰
ฟรีทุกอย่าง
ใช้โควต้า Google ฟรี
🔒
ข้อมูลเป็นของครู
เก็บใน Drive ตัวเอง

📌 KruBoard คืออะไร?

KruBoard เป็นระบบส่งการบ้านแบบกระดาน ที่ออกแบบมาเพื่อครูไทยโดยเฉพาะ ครูสร้าง "กระดาน" สำหรับการบ้านแต่ละชิ้น แล้วแจก QR code หรือลิงก์ให้นักเรียน นักเรียนถ่ายรูปงานพร้อมใส่เลขที่ส่งเข้ามา ครูก็ตรวจให้คะแนน เครื่องหมาย ✓ / ✗ / ★ และเขียนคอมเมนต์ได้ในจอเดียว เห็นงานทั้งห้องพร้อมกัน

จุดเด่นคือ ทำงานบน Google ทั้งหมด — ใช้ Google Sheets เป็นฐานข้อมูล, Google Drive เก็บรูป, และ Google Apps Script รันระบบ ทุกอย่างอยู่ในบัญชี Google ของครูเอง ไม่ต้องสมัครบริการอะไรเพิ่ม ไม่มีใครเห็นข้อมูลนักเรียนของเรา และไม่มีค่าใช้จ่ายรายเดือน

💡 เหมาะกับใคร
ครูทุกระดับชั้นที่อยากเก็บงานนักเรียนเป็นระบบ ลดการใช้กระดาษ และตรวจงานได้เร็วขึ้น — โดยเฉพาะห้องที่นักเรียนมีมือถือถ่ายรูปงานได้

✨ ความสามารถของระบบ

  • สร้างกระดานได้ไม่จำกัด — การบ้านแต่ละชิ้นคือกระดานหนึ่งใบ แยกตามวิชา/ห้อง/เรื่องได้
  • นักเรียนส่งง่ายมาก — สแกน QR ใส่เลขที่ ถ่ายรูป กดส่ง ระบบย่อรูปอัตโนมัติไม่กินเน็ต
  • ตรวจเห็นทั้งห้อง — รูปงานเรียงตามเลขที่ แตะดูได้ ซูม/เลื่อนภาพได้ ให้คะแนน + คอมเมนต์
  • ดูงานเพื่อนได้ (เปิด/ปิดได้) — ครูเลือกได้ว่าจะให้นักเรียนเห็นงานของเพื่อนในห้องไหม
  • สรุปผล + ดาวน์โหลด CSV — ดูคะแนนเฉลี่ย สูงสุด-ต่ำสุด และโหลดเข้าโปรแกรมคะแนนได้
  • มี PIN ป้องกัน — หน้าตรวจงานของครูล็อกด้วย PIN คนอื่นเปิดดูไม่ได้

🛠️ วิธีติดตั้ง (ทำตามทีละขั้น)

ใช้เวลาประมาณ 10 นาที ทำครั้งเดียวจบ แนะนำให้ทำบนคอมพิวเตอร์

ก่อนเริ่ม ต้องมี
บัญชี Google (Gmail) ที่ครูใช้ประจำ และคอมพิวเตอร์ · โค้ดทั้ง 2 ไฟล์อยู่ในบทความนี้แล้ว เลื่อนลงไปคัดลอกได้เลย
1 สร้าง Google Sheet เปล่า
  1. เปิดเว็บ sheets.google.com
  2. กดปุ่ม ว่าง (Blank) เพื่อสร้างชีตใหม่
  3. ตั้งชื่อไฟล์มุมซ้ายบนว่า KruBoard (หรือชื่ออะไรก็ได้)
ทำไม
ชีตนี้จะกลายเป็นทั้ง "ฐานข้อมูล" และ "ตัวโปรแกรม" ในไฟล์เดียว ไม่ต้องสร้างอะไรเพิ่ม
2 เปิดหน้าเขียนโค้ด
  1. ที่เมนูด้านบน กด ส่วนขยาย (Extensions) แล้วเลือก Apps Script
  2. จะมีแท็บใหม่เปิดขึ้นมาชื่อ "Apps Script" — หน้านี้คือที่วางโค้ด
3 วางโค้ด 2 ไฟล์

📄 ไฟล์แรก: Code.gs

  1. ในหน้า Apps Script จะเห็นไฟล์ Code.gs พร้อมโค้ดตัวอย่าง
  2. ลบโค้ดเดิมทิ้งให้หมด แล้ววางโค้ดด้านล่างนี้แทน
  3. กดปุ่มรูปแผ่นดิสก์ 💾 (บันทึก)
📄 Code.gs539 บรรทัด · กดค้างเพื่อเลือกทั้งหมด แล้วคัดลอก
/* ============================================================
 * KruBoard · Google Apps Script edition (Code.gs)
 * --------------------------------------------------------------
 * โมเดล: ครู 1 คน = 1 สำเนา (Sheet + Script ฝังในตัว)
 *   - ฐานข้อมูล  : Google Sheets (ชีต Boards, Subs)
 *   - เก็บรูป     : Google Drive (โฟลเดอร์ KruBoard ในไดรฟ์ครู)
 *   - กันหน้าครู : PIN (เก็บใน Script Properties แบบ hash)
 *
 * วิธีติดตั้ง (สรุป — มีคู่มือภาพแยก):
 *   1) เปิด Google Sheet เปล่า → เมนู ส่วนขยาย > Apps Script
 *   2) วางไฟล์นี้เป็น Code.gs และวาง Index.html เป็นไฟล์ HTML ชื่อ "Index"
 *   3) Deploy > New deployment > Web app
 *        - Execute as: Me (เจ้าของ)
 *        - Who has access: Anyone
 *   4) เปิดลิงก์ที่ได้ → ตั้ง PIN ครั้งแรก → ใช้งานได้เลย
 * ============================================================ */

/* ---------- ค่าคงที่ ---------- */
var SHEET_BOARDS = 'Boards';
var SHEET_SUBS   = 'Subs';
var DRIVE_FOLDER = 'KruBoard (รูปงานนักเรียน)';
var PROP_PIN     = 'KB_PIN_HASH';
var PROP_SALT    = 'KB_PIN_SALT';
var PROP_FOLDER  = 'KB_FOLDER_ID';
var PROP_SSID    = 'KB_SSID';        // เก็บ ID ของ Sheet ไว้ใช้ตอนเปิดผ่าน web app

/* หัวคอลัมน์ — ลำดับนี้ใช้ทั้งอ่านและเขียน ห้ามสลับ */
var BOARD_COLS = ['id', 'title', 'room', 'roster', 'peer', 'created'];
var SUB_COLS   = ['id', 'board', 'no', 'name', 'file_id', 'status', 'score', 'comment', 'created', 'reviewed'];

/* ============================================================
 * ENTRY POINTS
 * ============================================================ */

/**
 * รันอัตโนมัติเมื่อเปิดไฟล์ Sheet — บันทึก ID ของ Sheet ไว้
 * เพื่อให้ web app เปิดฐานข้อมูลได้ (ตอนเปิดผ่าน web app จะไม่มี active spreadsheet)
 */
function onOpen(e) {
  captureSsId_();
  try {
    SpreadsheetApp.getUi()
      .createMenu('KruBoard')
      .addItem('เชื่อมต่อฐานข้อมูล (รันครั้งแรก)', 'captureSsId_')
      .addItem('ล้าง PIN (ถ้าลืม)', 'resetPinManually')
      .addToUi();
  } catch (err) { /* ไม่มี UI ก็ข้าม */ }
}

/** บันทึก ID ของ Sheet ปัจจุบันลง Properties (เรียกจากบริบทที่มี active sheet) */
function captureSsId_() {
  try {
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    if (ss) {
      props_().setProperty(PROP_SSID, ss.getId());
      try { ss.toast('เชื่อมต่อฐานข้อมูลเรียบร้อย พร้อมใช้งาน', 'KruBoard', 4); } catch (e) {}
      return ss.getId();
    }
  } catch (err) {}
  return null;
}

/** เสิร์ฟหน้าเว็บ (ครู + นักเรียน ใช้ไฟล์เดียวกัน แยกด้วย ?board=) */
function doGet(e) {
  // พยายามเชื่อมต่อฐานข้อมูลก่อน (เก็บ ID ถ้ายังไม่มี)
  if (!props_().getProperty(PROP_SSID)) captureSsId_();
  try { ensureSheets_(); } catch (err) { /* ถ้ายังเชื่อมไม่ได้ ให้หน้าเว็บโหลดได้ แล้วค่อยแจ้ง error ตอนเรียก API */ }
  var t = HtmlService.createTemplateFromFile('Index');
  t.boardParam = (e && e.parameter && e.parameter.board) ? String(e.parameter.board) : '';
  t.webAppUrl  = ScriptApp.getService().getUrl();
  return t.evaluate()
    .setTitle('กระดานครู · KruBoard')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0, viewport-fit=cover')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

/**
 * API กลาง — frontend เรียกผ่าน google.script.run.apiCall(payload)
 * payload = { action, ...args, pin? }
 * คืน object เสมอ (มี .ok หรือ .error)
 */
function apiCall(payload) {
  // หุ้มผลลัพธ์เป็น JSON string เสมอ — เลี่ยงปัญหา google.script.run คืน null
  // เมื่อ object ซับซ้อน (timestamp, nested) serialize ไม่ผ่าน
  var result;
  try {
    result = apiDispatch_(payload);
  } catch (err) {
    result = { error: 'เกิดข้อผิดพลาด: ' + (err && err.message ? err.message : err) };
  }
  if (result === null || result === undefined) result = { error: 'ระบบไม่ตอบกลับ' };
  return JSON.stringify(result);
}

function apiDispatch_(payload) {
  try {
    payload = payload || {};
    var action = payload.action || '';

    /* ---- การกระทำสาธารณะ (นักเรียน — ไม่ต้อง PIN) ---- */
    switch (action) {
      case 'getBoardPublic': return getBoardPublic_(payload);
      case 'submitWork':     return submitWork_(payload);
      case 'getResult':      return getResult_(payload);
      case 'getPeer':        return getPeer_(payload);
      case 'pinStatus':      return { ok: true, hasPin: hasPin_() };
      case 'setupPin':       return setupPin_(payload);
      case 'verifyPin':      return { ok: true, valid: checkPin_(payload.pin) };
    }

    /* ---- ต่อจากนี้ต้องเป็นครู: ผ่าน PIN หรือเป็นเจ้าของ Sheet ---- */
    if (!isTeacher_(payload.pin)) {
      return { error: 'PIN ไม่ถูกต้อง หรือยังไม่ได้เข้าสู่ระบบครู', authFail: true };
    }

    switch (action) {
      case 'listBoards':  return listBoards_();
      case 'createBoard': return createBoard_(payload);
      case 'getSubs':     return getSubs_(payload);
      case 'gradeSub':    return gradeSub_(payload);
      case 'deleteSub':   return deleteSub_(payload);
      case 'deleteBoard': return deleteBoard_(payload);
    }

    return { error: 'ไม่รู้จักคำสั่ง: ' + action };
  } catch (err) {
    return { error: 'เกิดข้อผิดพลาด: ' + (err && err.message ? err.message : err) };
  }
}

/** ให้ Index.html ดึง partial (เผื่ออยากแยกไฟล์ในอนาคต) */
function include(name) {
  return HtmlService.createHtmlOutputFromFile(name).getContent();
}

/* ============================================================
 * PIN / AUTH
 * ============================================================ */

function props_() { return PropertiesService.getScriptProperties(); }

function hasPin_() { return !!props_().getProperty(PROP_PIN); }

/** เจ้าของ Sheet ถือว่าเป็นครูเสมอ (เผื่อ login Google อยู่) */
function isOwner_() {
  try {
    var me = Session.getEffectiveUser().getEmail();
    if (!me) return false;
    var owner = ss_().getOwner();
    return !!(owner && me === owner.getEmail());
  } catch (e) { return false; }
}

function isTeacher_(pin) {
  if (isOwner_()) return true;
  return checkPin_(pin);
}

function pinHash_(pin, salt) {
  var raw = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    String(salt) + '::' + String(pin),
    Utilities.Charset.UTF_8
  );
  return raw.map(function (b) {
    var v = (b < 0 ? b + 256 : b).toString(16);
    return v.length === 1 ? '0' + v : v;
  }).join('');
}

function setupPin_(payload) {
  var pin = (payload.pin || '').toString().trim();
  if (hasPin_() && !isOwner_()) {
    return { error: 'ตั้ง PIN ไว้แล้ว ถ้าลืมให้เจ้าของไฟล์รีเซ็ตในเมนู Apps Script' };
  }
  if (!/^\d{4,6}$/.test(pin)) return { error: 'PIN ต้องเป็นตัวเลข 4-6 หลัก' };
  var salt = Utilities.getUuid();
  props_().setProperty(PROP_SALT, salt);
  props_().setProperty(PROP_PIN, pinHash_(pin, salt));
  return { ok: true };
}

function checkPin_(pin) {
  if (!hasPin_()) return false;
  pin = (pin || '').toString().trim();
  if (!pin) return false;
  var salt = props_().getProperty(PROP_SALT);
  var want = props_().getProperty(PROP_PIN);
  return pinHash_(pin, salt) === want;
}

/** รันมือจากเมนู Apps Script ถ้าครูลืม PIN */
function resetPinManually() {
  props_().deleteProperty(PROP_PIN);
  props_().deleteProperty(PROP_SALT);
  try {
    SpreadsheetApp.getActive().toast('ล้าง PIN แล้ว เปิดเว็บแล้วตั้งใหม่ได้เลย', 'KruBoard', 5);
  } catch (e) { Logger.log('ล้าง PIN แล้ว'); }
}

/**
 * ⭐ รันฟังก์ชันนี้ครั้งเดียวก่อน Deploy (กดเลือก setupKruBoard แล้วกด ▶️ Run)
 * จะเชื่อมต่อฐานข้อมูล + สร้างชีต + สร้างโฟลเดอร์รูป ให้พร้อมใช้ทันที
 * ป้องกันอาการ "หมุนค้าง" ตอนเปิด web app ครั้งแรก
 */
function setupKruBoard() {
  var id = captureSsId_();
  ensureSheets_();
  getFolder_();
  Logger.log('✅ ติดตั้งเรียบร้อย! SSID = ' + id + ' — Deploy เป็น Web app ได้เลย');
  try {
    SpreadsheetApp.getActive().toast('✅ พร้อมใช้งาน! Deploy เป็น Web app ได้เลย', 'KruBoard', 5);
  } catch (e) {}
  return id;
}

/* ============================================================
 * SHEETS HELPERS (DB layer)
 * ============================================================ */

function ss_() {
  // ลองใช้ active ก่อน (กรณีรันจากใน Sheet) — เร็วสุด
  try {
    var active = SpreadsheetApp.getActiveSpreadsheet();
    if (active) {
      // เก็บ ID ไว้เผื่อ web app เรียกครั้งหน้า
      var sid = props_().getProperty(PROP_SSID);
      if (!sid) props_().setProperty(PROP_SSID, active.getId());
      return active;
    }
  } catch (e) { /* web app context — ไม่มี active, ไปใช้ ID แทน */ }

  // เปิดด้วย ID ที่เก็บไว้ (กรณีเปิดผ่าน web app)
  var id = props_().getProperty(PROP_SSID);
  if (id) {
    try { return SpreadsheetApp.openById(id); } catch (e2) {}
  }
  throw new Error('ยังไม่ได้เชื่อมต่อฐานข้อมูล — เปิดไฟล์ Google Sheet หนึ่งครั้ง (เมนู KruBoard > เชื่อมต่อฐานข้อมูล) แล้วลองใหม่');
}

function ensureSheets_() {
  var ss = ss_();
  ensureSheetWithHeader_(ss, SHEET_BOARDS, BOARD_COLS);
  ensureSheetWithHeader_(ss, SHEET_SUBS, SUB_COLS);
}

function ensureSheetWithHeader_(ss, name, cols) {
  var sh = ss.getSheetByName(name);
  if (!sh) sh = ss.insertSheet(name);
  var first = sh.getRange(1, 1, 1, cols.length).getValues()[0];
  var empty = first.every(function (c) { return c === '' || c === null; });
  if (empty) {
    sh.getRange(1, 1, 1, cols.length).setValues([cols]);
    sh.setFrozenRows(1);
  }
  return sh;
}

function sheet_(name) { return ss_().getSheetByName(name); }

/** อ่านทุกแถวเป็น array ของ object ตามหัวคอลัมน์ */
function readRows_(name, cols) {
  var sh = sheet_(name);
  var last = sh.getLastRow();
  if (last < 2) return [];
  var values = sh.getRange(2, 1, last - 1, cols.length).getValues();
  return values.map(function (row, i) {
    var o = { _row: i + 2 };
    cols.forEach(function (c, j) { o[c] = row[j]; });
    return o;
  });
}

function appendRow_(name, cols, obj) {
  var sh = sheet_(name);
  var row = cols.map(function (c) { return obj[c] != null ? obj[c] : ''; });
  sh.appendRow(row);
}

function updateRow_(name, cols, rowIndex, patch) {
  var sh = sheet_(name);
  var current = sh.getRange(rowIndex, 1, 1, cols.length).getValues()[0];
  cols.forEach(function (c, j) {
    if (patch.hasOwnProperty(c)) current[j] = patch[c] != null ? patch[c] : '';
  });
  sh.getRange(rowIndex, 1, 1, cols.length).setValues([current]);
}

function deleteRow_(name, rowIndex) {
  sheet_(name).deleteRow(rowIndex);
}

function uid_() { return Utilities.getUuid().replace(/-/g, '').slice(0, 8); }
function now_() { return Date.now(); }

/* ใช้ LockService กันเขียนชนกัน (นักเรียนส่งพร้อมกันหลายคน) */
function withLock_(fn) {
  var lock = LockService.getScriptLock();
  lock.waitLock(20000);
  try { return fn(); }
  finally { lock.releaseLock(); }
}

/* ============================================================
 * DRIVE HELPERS (image storage)
 * ============================================================ */

function getFolder_() {
  var id = props_().getProperty(PROP_FOLDER);
  if (id) {
    try { return DriveApp.getFolderById(id); } catch (e) { /* หาย → สร้างใหม่ */ }
  }
  var folder = DriveApp.createFolder(DRIVE_FOLDER);
  props_().setProperty(PROP_FOLDER, folder.getId());
  return folder;
}

/** บันทึกรูปจาก base64 dataURL → คืน fileId */
function saveImage_(dataUrl, filename) {
  var m = /^data:([^;]+);base64,(.*)$/.exec(dataUrl || '');
  if (!m) throw new Error('รูปไม่ถูกต้อง');
  var contentType = m[1];
  var bytes = Utilities.base64Decode(m[2]);
  var blob = Utilities.newBlob(bytes, contentType, filename || 'work.jpg');
  var file = getFolder_().createFile(blob);
  // ให้ "ใครมีลิงก์ก็เปิดได้" เพื่อให้ <img> แสดงผลฝั่งนักเรียน
  try {
    file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  } catch (e) { /* domain บางที่จำกัด — รูปจะเสิร์ฟผ่าน proxy แทน */ }
  return file.getId();
}

function deleteImage_(fileId) {
  if (!fileId) return;
  try { DriveApp.getFileById(fileId).setTrashed(true); } catch (e) {}
}

/** URL รูปสำหรับแสดงผล — ใช้ thumbnail ของ Drive (เบา + เร็ว) */
function imageUrl_(fileId) {
  if (!fileId) return '';
  return 'https://drive.google.com/thumbnail?id=' + fileId + '&sz=w1000';
}

/* ============================================================
 * PUBLIC ACTIONS (นักเรียน)
 * ============================================================ */

function getBoardPublic_(payload) {
  var id = (payload.board || '').toString();
  var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
  var b = findBy_(boards, 'id', id);
  if (!b) return { error: 'ไม่พบกระดานนี้' };
  return {
    ok: true,
    board: { id: b.id, title: b.title, room: b.room, peer: Number(b.peer) ? 1 : 0 }
  };
}

function submitWork_(payload) {
  return withLock_(function () {
    var board = (payload.board || '').toString();
    var no = parseInt(payload.no, 10);
    var name = (payload.name || '').toString().slice(0, 60);
    if (!board || !no || !payload.image) return { error: 'ข้อมูลไม่ครบ' };

    var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
    if (!findBy_(boards, 'id', board)) return { error: 'ไม่พบกระดาน' };

    var fileId = saveImage_(payload.image, board + '-no' + no + '.jpg');

    // ถ้าเลขที่นี้เคยส่งแล้ว → ลบรูปเก่า แล้วเขียนทับ (รีเซ็ตสถานะเป็น wait)
    var subs = readRows_(SHEET_SUBS, SUB_COLS);
    var existing = subs.filter(function (s) {
      return String(s.board) === board && parseInt(s.no, 10) === no;
    })[0];

    if (existing) {
      deleteImage_(existing.file_id);
      updateRow_(SHEET_SUBS, SUB_COLS, existing._row, {
        name: name, file_id: fileId, status: 'wait',
        score: '', comment: '', created: now_(), reviewed: ''
      });
    } else {
      appendRow_(SHEET_SUBS, SUB_COLS, {
        id: uid_(), board: board, no: no, name: name, file_id: fileId,
        status: 'wait', score: '', comment: '', created: now_(), reviewed: ''
      });
    }
    return { ok: true };
  });
}

function getResult_(payload) {
  var board = (payload.board || '').toString();
  var no = parseInt(payload.no, 10);
  if (!board || !no) return { error: 'ข้อมูลไม่ครบ' };
  var subs = readRows_(SHEET_SUBS, SUB_COLS);
  var s = subs.filter(function (x) {
    return String(x.board) === board && parseInt(x.no, 10) === no;
  })[0];
  if (!s) return { error: 'ยังไม่พบงานของเลขที่นี้' };
  return {
    ok: true,
    sub: {
      no: s.no, name: s.name, img: imageUrl_(s.file_id),
      status: s.status || 'wait', score: s.score, comment: s.comment
    }
  };
}

/**
 * งานเพื่อนในห้อง (สาธารณะ) — เปิดให้ดูเฉพาะเมื่อบอร์ดตั้ง peer=1
 * คืนแค่ เลขที่ + รูป เท่านั้น ไม่คืนคะแนน/คอมเมนต์ (กันนักเรียนเห็นคะแนนกัน)
 */
function getPeer_(payload) {
  var id = (payload.board || '').toString();
  var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
  var b = findBy_(boards, 'id', id);
  if (!b) return { error: 'ไม่พบกระดาน' };
  if (!Number(b.peer)) return { ok: true, peer: false, subs: [] };
  var subs = readRows_(SHEET_SUBS, SUB_COLS)
    .filter(function (s) { return String(s.board) === id; })
    .sort(function (a, b2) { return parseInt(a.no, 10) - parseInt(b2.no, 10); })
    .map(function (s) { return { no: s.no, name: s.name, img: imageUrl_(s.file_id) }; });
  return { ok: true, peer: true, subs: subs };
}

/* ============================================================
 * TEACHER ACTIONS (ต้องผ่าน PIN)
 * ============================================================ */

function listBoards_() {
  var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
  var subs = readRows_(SHEET_SUBS, SUB_COLS);
  var byBoard = {};
  subs.forEach(function (s) {
    var k = String(s.board);
    if (!byBoard[k]) byBoard[k] = { submitted: 0, reviewed: 0 };
    byBoard[k].submitted++;
    if (s.status && s.status !== 'wait') byBoard[k].reviewed++;
  });
  var out = boards.map(function (b) {
    var c = byBoard[String(b.id)] || { submitted: 0, reviewed: 0 };
    return {
      id: b.id, title: b.title, room: b.room,
      roster: Number(b.roster) || 0, peer: Number(b.peer) ? 1 : 0,
      created: b.created, submitted: c.submitted, reviewed: c.reviewed
    };
  });
  out.sort(function (a, b) { return (b.created || 0) - (a.created || 0); });
  return { ok: true, boards: out };
}

function createBoard_(payload) {
  var title = (payload.title || '').toString().slice(0, 120).trim();
  if (!title) return { error: 'ต้องมีชื่อการบ้าน' };
  var id = uid_();
  appendRow_(SHEET_BOARDS, BOARD_COLS, {
    id: id, title: title,
    room: (payload.room || '').toString().slice(0, 40),
    roster: parseInt(payload.roster, 10) || 0,
    peer: payload.peer ? 1 : 0,
    created: now_()
  });
  return { ok: true, id: id };
}

function getSubs_(payload) {
  var board = (payload.board || '').toString();
  var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
  if (!findBy_(boards, 'id', board)) return { error: 'ไม่พบกระดาน' };
  var subs = readRows_(SHEET_SUBS, SUB_COLS)
    .filter(function (s) { return String(s.board) === board; })
    .sort(function (a, b) { return parseInt(a.no, 10) - parseInt(b.no, 10); });
  var out = subs.map(function (s) {
    return {
      id: s.id, no: s.no, name: s.name, img: imageUrl_(s.file_id),
      status: s.status || 'wait', score: s.score, comment: s.comment
    };
  });
  return { ok: true, subs: out };
}

function gradeSub_(payload) {
  return withLock_(function () {
    var sid = (payload.id || '').toString();
    var subs = readRows_(SHEET_SUBS, SUB_COLS);
    var s = findBy_(subs, 'id', sid);
    if (!s) return { error: 'ไม่พบงานนี้' };
    updateRow_(SHEET_SUBS, SUB_COLS, s._row, {
      status: payload.status || 'wait',
      score: payload.score != null ? payload.score : '',
      comment: payload.comment != null ? payload.comment : '',
      reviewed: now_()
    });
    return { ok: true };
  });
}

function deleteSub_(payload) {
  return withLock_(function () {
    var sid = (payload.id || '').toString();
    var subs = readRows_(SHEET_SUBS, SUB_COLS);
    var s = findBy_(subs, 'id', sid);
    if (!s) return { error: 'ไม่พบงานนี้' };
    deleteImage_(s.file_id);
    deleteRow_(SHEET_SUBS, s._row);
    return { ok: true };
  });
}

function deleteBoard_(payload) {
  return withLock_(function () {
    var board = (payload.board || '').toString();
    var subs = readRows_(SHEET_SUBS, SUB_COLS)
      .filter(function (s) { return String(s.board) === board; });
    // ลบรูปทั้งหมดก่อน
    subs.forEach(function (s) { deleteImage_(s.file_id); });
    // ลบแถว subs จากล่างขึ้นบน (กัน index เลื่อน)
    subs.map(function (s) { return s._row; })
        .sort(function (a, b) { return b - a; })
        .forEach(function (r) { deleteRow_(SHEET_SUBS, r); });
    // ลบตัวบอร์ด
    var boards = readRows_(SHEET_BOARDS, BOARD_COLS);
    var b = findBy_(boards, 'id', board);
    if (b) deleteRow_(SHEET_BOARDS, b._row);
    return { ok: true };
  });
}

/* ---------- util ---------- */
function findBy_(arr, key, val) {
  val = String(val);
  for (var i = 0; i < arr.length; i++) {
    if (String(arr[i][key]) === val) return arr[i];
  }
  return null;
}

📄 ไฟล์ที่สอง: Index.html (สร้างใหม่)

  1. กดเครื่องหมาย + ข้างคำว่า "ไฟล์ (Files)" แล้วเลือก HTML
  2. ตั้งชื่อไฟล์ว่า Index เป๊ะๆ (ตัว I ใหญ่ ไม่ต้องพิมพ์ .html)
  3. ลบโค้ดเดิมในไฟล์ทิ้ง แล้ววางโค้ดด้านล่างนี้แทน
  4. กดบันทึก 💾
⚠️ สำคัญ
ชื่อไฟล์ต้องเป็น Index เท่านั้น ถ้าตั้งชื่ออื่นระบบจะหาหน้าเว็บไม่เจอ
📄 Index.html635 บรรทัด · กดค้างเพื่อเลือกทั้งหมด แล้วคัดลอก
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<base target="_top">
<style>
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Thai:wght@400;500;600;700&family=Sarabun:wght@400;600;700&display=swap');
  :root{
    --paper:#F6F4EF;--card:#FFFFFF;--ink:#23201B;--ink-soft:#6B655C;--line:#E5E0D6;
    --chalk:#1F6F5C;--chalk-deep:#155244;--marker:#E8643C;--marker-soft:#FBE7DF;
    --star:#E5A516;--ok:#2E9E6B;
    --shadow:0 1px 2px rgba(35,32,27,.05),0 8px 24px rgba(35,32,27,.06);
    --shadow-lg:0 12px 40px rgba(35,32,27,.14);--r:18px;--r-sm:12px;
  }
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  html,body{margin:0;padding:0}
  body{font-family:'IBM Plex Sans Thai','Sarabun',system-ui,sans-serif;background:var(--paper);color:var(--ink);line-height:1.55;-webkit-font-smoothing:antialiased;overscroll-behavior-y:none}
  button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
  input,select,textarea{font-family:inherit;font-size:16px}
  .hidden{display:none!important}
  .wrap{max-width:1080px;margin:0 auto;padding:0 16px}
  .topbar{position:sticky;top:0;z-index:40;background:rgba(246,244,239,.85);backdrop-filter:saturate(180%) blur(12px);border-bottom:1px solid var(--line)}
  .topbar .wrap{display:flex;align-items:center;gap:12px;height:60px}
  .brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:18px;letter-spacing:-.01em}
  .brand .logo{width:30px;height:30px;border-radius:9px;background:var(--chalk);display:grid;place-items:center;color:#fff;font-weight:700;font-size:15px;box-shadow:inset 0 0 0 2px rgba(255,255,255,.18);transform:rotate(-4deg)}
  .brand small{color:var(--ink-soft);font-weight:500;font-size:12.5px;margin-left:2px}
  .who{font-size:13px;color:var(--ink-soft);display:flex;align-items:center;gap:8px;margin-left:auto}
  .who .dot{width:8px;height:8px;border-radius:50%;background:var(--ok)}
  .who button{font-size:12px;color:var(--marker);font-weight:600}
  .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 18px;border-radius:12px;font-weight:600;font-size:15px;background:var(--card);border:1px solid var(--line);color:var(--ink);transition:transform .08s,box-shadow .15s,background .15s}
  .btn:active{transform:scale(.97)}
  .btn.primary{background:var(--chalk);color:#fff;border-color:transparent;box-shadow:0 6px 16px rgba(31,111,92,.28)}
  .btn.primary:active{background:var(--chalk-deep)}
  .btn.ghost{background:transparent}.btn.sm{padding:8px 13px;font-size:13.5px;border-radius:10px}.btn.block{width:100%}
  .btn.danger{color:var(--marker);border-color:#f3d4c9}
  .btn:disabled{opacity:.5;pointer-events:none}
  .hero{padding:54px 0 30px;text-align:center}
  .hero h1{font-size:clamp(30px,7vw,46px);line-height:1.08;letter-spacing:-.02em;margin:0 0 14px;font-weight:700}
  .hero h1 .hl{color:var(--chalk);position:relative;white-space:nowrap}
  .hero h1 .hl::after{content:"";position:absolute;left:-2px;right:-2px;bottom:4px;height:10px;background:rgba(31,111,92,.16);z-index:-1;border-radius:3px}
  .hero p{max-width:520px;margin:0 auto;color:var(--ink-soft);font-size:17px}
  .roles{display:grid;grid-template-columns:1fr 1fr;gap:14px;max-width:560px;margin:30px auto 0}
  .role{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:20px 18px;box-shadow:var(--shadow);transition:transform .12s,box-shadow .15s}
  .role:active{transform:translateY(1px)}
  .role .ic{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;font-size:21px;margin-bottom:12px}
  .role.t .ic{background:var(--chalk);color:#fff}.role.s .ic{background:var(--marker-soft);color:var(--marker)}
  .role h3{margin:0 0 4px;font-size:18px}.role span{font-size:13.5px;color:var(--ink-soft)}
  @media(max-width:520px){.roles{grid-template-columns:1fr}}
  .page{padding:24px 0 100px;animation:fade .25s ease}
  @keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
  .phead{display:flex;align-items:center;gap:12px;margin-bottom:20px}
  .phead h2{margin:0;font-size:23px;letter-spacing:-.01em}.phead .sub{color:var(--ink-soft);font-size:14px}
  .back{font-size:14px;color:var(--ink-soft);display:inline-flex;gap:5px;align-items:center;padding:6px 0;margin-bottom:4px}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
  .bcard{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:18px;box-shadow:var(--shadow);text-align:left;position:relative;overflow:hidden;transition:transform .12s,box-shadow .15s}
  .bcard:active{transform:translateY(1px)}
  .bcard .tag{display:inline-block;font-size:12px;font-weight:600;color:var(--chalk-deep);background:rgba(31,111,92,.1);padding:3px 9px;border-radius:20px;margin-bottom:10px}
  .bcard h3{margin:0 0 4px;font-size:17px;line-height:1.3}.bcard .meta{font-size:13px;color:var(--ink-soft)}
  .bcard .prog{margin-top:14px;height:7px;border-radius:10px;background:var(--line);overflow:hidden}
  .bcard .prog>i{display:block;height:100%;background:var(--chalk);border-radius:10px;transition:width .4s}
  .bcard .pnum{font-size:12.5px;color:var(--ink-soft);margin-top:7px;display:flex;justify-content:space-between}
  .bcard .cardmenu{position:absolute;top:12px;right:12px;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--ink-soft);font-size:18px;background:var(--paper)}
  .newcard{border:1.5px dashed var(--line);background:transparent;border-radius:var(--r);display:grid;place-items:center;min-height:150px;color:var(--ink-soft);font-weight:600;gap:8px;transition:border-color .15s,color .15s}
  .newcard:hover{border-color:var(--chalk);color:var(--chalk)}
  .newcard .plus{width:38px;height:38px;border-radius:50%;background:var(--card);border:1px solid var(--line);display:grid;place-items:center;font-size:22px}
  .empty{text-align:center;padding:50px 20px;color:var(--ink-soft)}.empty .big{font-size:40px;margin-bottom:8px}
  .field{margin-bottom:16px}.field label{display:block;font-size:13.5px;font-weight:600;margin-bottom:6px}
  .field input,.field select,.field textarea{width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:var(--card);color:var(--ink);transition:border-color .15s,box-shadow .15s}
  .field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--chalk);box-shadow:0 0 0 3px rgba(31,111,92,.12)}
  .toggle{display:flex;align-items:center;justify-content:space-between;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
  .toggle .lab{font-size:14px;font-weight:600}.toggle .lab small{display:block;font-weight:400;color:var(--ink-soft);font-size:12.5px;margin-top:2px}
  .sw{width:48px;height:28px;border-radius:20px;background:var(--line);position:relative;transition:background .2s;flex:none}
  .sw::after{content:"";position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);transition:left .2s}
  .sw.on{background:var(--chalk)}.sw.on::after{left:23px}
  .share{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:22px;text-align:center;box-shadow:var(--shadow)}
  .qrbox{width:188px;height:188px;margin:6px auto 16px;background:#fff;border-radius:14px;border:1px solid var(--line);display:grid;place-items:center;padding:12px}
  .qrbox img,.qrbox canvas{width:100%;height:100%}
  .linkrow{display:flex;gap:8px;margin-top:8px}
  .linkrow input{flex:1;font-size:13px;padding:11px 12px;border:1px solid var(--line);border-radius:10px;background:var(--paper);color:var(--ink-soft)}
  .scard{max-width:460px;margin:0 auto}
  .stask{background:var(--chalk);color:#fff;border-radius:var(--r);padding:20px;margin-bottom:18px;box-shadow:0 8px 24px rgba(31,111,92,.25)}
  .stask .tag{font-size:12px;opacity:.85;font-weight:600}.stask h2{margin:4px 0 2px;font-size:22px}.stask .rm{font-size:13.5px;opacity:.9}
  .drop{border:1.5px dashed var(--line);border-radius:var(--r);background:var(--card);padding:26px 18px;text-align:center;color:var(--ink-soft);transition:border-color .15s,background .15s;display:block}
  .drop.has{border-style:solid;border-color:var(--chalk);padding:12px}
  .drop .cam{width:54px;height:54px;border-radius:50%;background:var(--marker-soft);color:var(--marker);display:grid;place-items:center;font-size:26px;margin:0 auto 10px}
  .preview{width:100%;border-radius:12px;display:block}
  .shrink-note{font-size:12px;color:var(--ok);margin-top:8px;display:flex;align-items:center;justify-content:center;gap:5px}
  .toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
  .seg{display:inline-flex;background:var(--card);border:1px solid var(--line);border-radius:11px;padding:3px;gap:2px}
  .seg button{padding:7px 13px;border-radius:8px;font-size:13.5px;font-weight:600;color:var(--ink-soft)}
  .seg button.on{background:var(--chalk);color:#fff}
  .mgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
  .work{background:var(--card);border:1px solid var(--line);border-radius:var(--r-sm);overflow:hidden;box-shadow:var(--shadow);position:relative;transition:transform .12s,box-shadow .15s}
  .work:active{transform:scale(.985)}
  .work .thumb{width:100%;aspect-ratio:3/4;object-fit:cover;display:block;background:var(--line)}
  .work .wfoot{padding:8px 10px;display:flex;align-items:center;justify-content:space-between;gap:6px}
  .work .no{font-weight:700;font-size:14px}.work .nm{font-size:12px;color:var(--ink-soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .badge{position:absolute;top:8px;right:8px;width:26px;height:26px;border-radius:50%;display:grid;place-items:center;font-size:14px;color:#fff;box-shadow:0 2px 6px rgba(0,0,0,.2)}
  .badge.ok{background:var(--ok)}.badge.no{background:var(--marker)}.badge.star{background:var(--star)}
  .work .scorechip{font-size:12px;font-weight:700;color:var(--chalk-deep);background:rgba(31,111,92,.1);padding:2px 7px;border-radius:8px}
  .pending-ring{position:absolute;top:8px;left:8px;width:10px;height:10px;border-radius:50%;background:var(--marker);box-shadow:0 0 0 3px rgba(232,100,60,.2)}
  .viewer{position:fixed;inset:0;z-index:60;background:rgba(20,18,15,.96);display:flex;flex-direction:column;animation:fade .2s}
  .vtop{display:flex;align-items:center;gap:12px;padding:14px 16px;color:#fff;padding-top:max(14px,env(safe-area-inset-top))}
  .vtop .vname{font-weight:700;font-size:16px}.vtop .vsub{font-size:12.5px;opacity:.7}
  .vtools{margin-left:auto;display:flex;gap:8px}
  .vbtn{width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;display:grid;place-items:center;font-size:18px}
  .vbtn.danger{background:rgba(232,100,60,.22)}
  .vstage{flex:1;position:relative;display:grid;place-items:center;overflow:hidden;touch-action:none}
  .vstage img{max-width:100%;max-height:100%;object-fit:contain;border-radius:8px;will-change:transform;transition:transform .05s linear;user-select:none;-webkit-user-drag:none}
  .varrow{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border-radius:50%;background:rgba(255,255,255,.14);color:#fff;display:grid;place-items:center;font-size:22px;z-index:2}
  .varrow.l{left:10px}.varrow.r{right:10px}.varrow:disabled{opacity:.25}
  .vhint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);font-size:11.5px;color:rgba(255,255,255,.55);z-index:2;pointer-events:none}
  .vmark{background:#fff;padding:14px 16px;padding-bottom:max(14px,env(safe-area-inset-bottom));border-radius:20px 20px 0 0}
  .stamps{display:flex;gap:10px;margin-bottom:12px}
  .stamp{flex:1;padding:12px;border-radius:13px;border:1.5px solid var(--line);font-weight:700;font-size:15px;display:flex;align-items:center;justify-content:center;gap:7px;background:var(--card);transition:.12s}
  .stamp.ok.on{background:var(--ok);color:#fff;border-color:transparent}
  .stamp.no.on{background:var(--marker);color:#fff;border-color:transparent}
  .stamp.star.on{background:var(--star);color:#fff;border-color:transparent}
  .markrow{display:flex;gap:10px;align-items:stretch}
  .markrow .sc{width:118px;flex:none}.markrow .sc input{text-align:center;font-weight:700;font-size:18px}
  .markrow textarea{flex:1;resize:none;height:50px;padding:9px 12px}
  .vsavebar{display:flex;gap:10px;margin-top:12px}
  .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:20px}
  .stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px;text-align:center}
  .stat .v{font-size:26px;font-weight:700;letter-spacing:-.02em}.stat .k{font-size:12px;color:var(--ink-soft);margin-top:2px}
  .stat.accent .v{color:var(--chalk)}
  @media(max-width:520px){.stats{grid-template-columns:repeat(2,1fr)}}
  table{width:100%;border-collapse:collapse;background:var(--card);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow)}
  th,td{text-align:left;padding:11px 14px;font-size:14px;border-bottom:1px solid var(--line)}
  th{background:var(--paper);font-size:12.5px;color:var(--ink-soft);font-weight:600}
  tr:last-child td{border-bottom:none}
  .st-chip{font-size:11.5px;font-weight:600;padding:2px 8px;border-radius:20px}
  .st-chip.done{background:rgba(46,158,107,.12);color:var(--ok)}.st-chip.wait{background:rgba(232,100,60,.12);color:var(--marker)}
  .result{max-width:460px;margin:0 auto;text-align:center}
  .rsheet{background:var(--card);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--shadow);margin-top:14px}
  .rsheet img{width:100%;display:block}
  .rstamp{font-size:46px;padding:14px 0 4px}.rscore{font-size:34px;font-weight:700;color:var(--chalk)}
  .rcomment{background:var(--marker-soft);color:#8a3a22;border-radius:13px;padding:13px 15px;margin:14px;font-size:14.5px;text-align:left;position:relative}
  .rcomment::before{content:"ครูเขียนถึงเธอ";display:block;font-size:11px;font-weight:700;color:var(--marker);margin-bottom:4px}
  .peerwrap{margin-top:26px}
  .peerwrap h3{font-size:15px;margin:0 0 10px;color:var(--ink-soft);font-weight:600;display:flex;align-items:center;gap:7px}
  .peergrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}
  .peercell{border-radius:10px;overflow:hidden;border:1px solid var(--line);background:var(--card);position:relative}
  .peercell img{width:100%;aspect-ratio:3/4;object-fit:cover;display:block}
  .peercell .pno{position:absolute;bottom:0;left:0;right:0;background:rgba(35,32,27,.7);color:#fff;font-size:11px;padding:2px 5px;font-weight:600}
  .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(20px);background:var(--ink);color:#fff;padding:12px 20px;border-radius:30px;font-size:14px;font-weight:600;opacity:0;transition:.25s;z-index:90;box-shadow:var(--shadow-lg);max-width:90vw;text-align:center}
  .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
  .modal-bg{position:fixed;inset:0;background:rgba(20,18,15,.4);z-index:70;display:grid;place-items:center;padding:18px;animation:fade .2s}
  .modal{background:var(--card);border-radius:20px;padding:22px;max-width:420px;width:100%;box-shadow:var(--shadow-lg);max-height:90vh;overflow:auto}
  .modal h3{margin:0 0 16px;font-size:20px}
  .row2{display:flex;gap:10px}.row2>*{flex:1}
  .spin{width:34px;height:34px;border:3px solid var(--line);border-top-color:var(--chalk);border-radius:50%;animation:sp 1s linear infinite;margin:30px auto}
  @keyframes sp{to{transform:rotate(360deg)}}
  .pingate{max-width:380px;margin:50px auto;background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:28px;box-shadow:var(--shadow);text-align:center}
  .pingate .ic{width:54px;height:54px;border-radius:14px;background:var(--chalk);color:#fff;display:grid;place-items:center;font-size:26px;margin:0 auto 14px}
  .pingate h2{margin:0 0 6px;font-size:21px}.pingate p{margin:0 0 18px;color:var(--ink-soft);font-size:14px}
  .pininput{font-size:26px!important;text-align:center;letter-spacing:10px;font-weight:700}
</style>
</head>
<body>
<div class="topbar"><div class="wrap">
  <div class="brand"><span class="logo">ค</span><span>กระดานครู<small>KruBoard</small></span></div>
  <div id="whoami" class="who hidden"><span class="dot"></span><span id="whoTxt"></span><button onclick="teacherSignOut()">ออก</button></div>
</div></div>
<div id="app"></div>
<div class="toast" id="toast"></div>

<script>
/* ============================================================
   KruBoard · frontend (Google Apps Script edition)
   transport: google.script.run  (ไม่ใช่ fetch)
   ============================================================ */

// ค่าจาก server (ฝังตอน render)
const BOARD_PARAM = '<?= boardParam ?>';
const WEBAPP_URL  = '<?= webAppUrl ?>';

// PIN ครู — เก็บใน sessionStorage (ปิดแท็บแล้วถามใหม่ ปลอดภัยกว่า)
let _pin = sessionStorage.getItem('kb_pin') || null;

/* ---- เรียก server แบบ Promise (หุ้ม google.script.run) ---- */
function call(action, args) {
  return new Promise((resolve, reject) => {
    const payload = Object.assign({ action: action, pin: _pin }, args || {});
    google.script.run
      .withSuccessHandler(raw => {
        let res;
        try { res = (typeof raw === 'string') ? JSON.parse(raw) : raw; }
        catch (e) { reject(new Error('ข้อมูลที่ได้รับไม่ถูกต้อง')); return; }
        if (!res) { reject(new Error('ระบบไม่ตอบกลับ')); return; }
        if (res.authFail) { teacherSignOut(); reject(new Error(res.error)); return; }
        if (res.error) { reject(new Error(res.error)); return; }
        resolve(res);
      })
      .withFailureHandler(err => reject(new Error((err && err.message) || 'การเชื่อมต่อมีปัญหา')))
      .apiCall(payload);
  });
}

/* ---- image compression (เหมือนเดิม แต่คืน dataURL สำหรับ GAS) ---- */
function compress(file, maxW = 1200, quality = .72) {
  return new Promise((res, rej) => {
    const img = new Image();
    img.onload = () => {
      let { width: w, height: h } = img;
      if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; }
      const c = document.createElement('canvas'); c.width = w; c.height = h;
      c.getContext('2d').drawImage(img, 0, 0, w, h);
      const dataUrl = c.toDataURL('image/jpeg', quality);
      res({ dataUrl, origKB: Math.round(file.size/1024), newKB: Math.round((dataUrl.length*0.75)/1024) });
    };
    img.onerror = () => rej(new Error('เปิดรูปไม่ได้'));
    img.src = URL.createObjectURL(file);
  });
}

/* ---- helpers ---- */
const $ = s => document.querySelector(s);
const app = $('#app');
const go = h => { location.hash = h; };
function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(t._);t._=setTimeout(()=>t.classList.remove('show'),2600)}
function esc(s){return(s||'').toString().replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
function setWho(t){const w=$('#whoami');if(t){w.classList.remove('hidden');$('#whoTxt').textContent=t}else w.classList.add('hidden')}
function loading(){app.innerHTML='<div class="wrap"><div class="spin"></div></div>'}
function qrURL(text){return 'https://api.qrserver.com/v1/create-qr-code/?size=320x320&margin=0&data='+encodeURIComponent(text)}
function studentLink(boardId){return WEBAPP_URL+'?board='+encodeURIComponent(boardId)}

/* ---- teacher session ---- */
function teacherSignOut(){_pin=null;sessionStorage.removeItem('kb_pin');go('')}

/* ============================================================ ROUTER */
async function route(){
  const parts = location.hash.slice(1).split('/');
  const view = parts[0], arg = parts[1];

  // ถ้าเปิดด้วย ?board= → นักเรียนเข้าหน้าส่งงานทันที (ไม่ต้องเห็นหน้าครูเลย)
  if (BOARD_PARAM && !view) return studentSubmit(BOARD_PARAM);

  if (view==='t')      return teacherGate(teacherHome);
  if (view==='board')  return teacherGate(()=>teacherBoard(arg));
  if (view==='new')    return teacherGate(teacherNew);
  if (view==='share')  return teacherGate(()=>shareView(arg));
  if (view==='sum')    return teacherGate(()=>summaryView(arg));
  if (view==='s')      return studentSubmit(arg);
  if (view==='r')      return studentResult(arg);
  return landing();
}
window.addEventListener('hashchange', route);

/* ============================================================ LANDING */
function landing(){
  setWho(_pin?'โหมดครู':'');
  app.innerHTML=`<div class="wrap">
    <div class="hero">
      <h1>ส่งการบ้าน<br>ตรวจง่าย เห็น<span class="hl">ทั้งห้อง</span>ในจอเดียว</h1>
      <p>นักเรียนถ่ายงานส่งในไม่กี่วิ ครูกวาดตาดูทั้งห้อง ให้คะแนนและคอมเมนต์ได้ทันที</p>
    </div>
    <div class="roles">
      <button class="role t" onclick="go('t')"><div class="ic">ค</div><h3>ฉันเป็นครู</h3><span>ใส่ PIN เพื่อสร้างและตรวจกระดาน</span></button>
      <button class="role s" onclick="openStudentEntry()"><div class="ic">นร</div><h3>ฉันเป็นนักเรียน</h3><span>ส่งงาน หรือดูผลการตรวจของตัวเอง</span></button>
    </div>
  </div>`;
}
function openStudentEntry(){
  modal(`<h3>เข้ากระดานของห้อง</h3>
    <div class="field"><label>รหัสกระดาน (จากครู)</label><input id="bc" placeholder="เช่น ab12cd34" autocomplete="off"></div>
    <div class="row2"><button class="btn ghost" onclick="closeModal()">ยกเลิก</button><button class="btn primary" onclick="enterBoard()">ไปส่งงาน</button></div>`);
  setTimeout(()=>$('#bc')?.focus(),100);
}
function enterBoard(){const c=$('#bc').value.trim();closeModal();if(c)go('s/'+c)}

/* ============================================================ PIN GATE */
async function teacherGate(next){
  // ถ้ามี PIN ใน session แล้ว → ตรวจสอบความถูกต้องเงียบๆ ครั้งเดียว
  if(_pin){
    try{const r=await call('verifyPin',{});if(r.valid){return next();}}catch(e){}
    _pin=null;sessionStorage.removeItem('kb_pin');
  }
  setWho('');
  let st;try{st=await call('pinStatus',{})}catch(e){return toast(e.message)}
  if(!st.hasPin) return pinSetup(next);
  pinEnter(next);
}
function pinSetup(next){
  app.innerHTML=`<div class="wrap"><div class="pingate">
    <div class="ic">🔐</div><h2>ตั้ง PIN ครูครั้งแรก</h2>
    <p>ตั้งรหัส 4-6 หลักไว้ป้องกันหน้าตรวจงาน จำให้ดีนะ — ใช้ทุกครั้งที่เข้าโหมดครู</p>
    <div class="field"><input id="p1" class="pininput" type="password" inputmode="numeric" maxlength="6" placeholder="••••" onkeydown="if(event.key==='Enter')$('#p2').focus()"></div>
    <div class="field"><input id="p2" class="pininput" type="password" inputmode="numeric" maxlength="6" placeholder="ยืนยันอีกครั้ง" onkeydown="if(event.key==='Enter')doPinSetup()"></div>
    <button class="btn primary block" id="pBtn" onclick="doPinSetup()">ตั้ง PIN</button>
    <button class="btn ghost block" style="margin-top:8px" onclick="go('')">กลับ</button>
  </div></div>`;
  window._afterPin=next;
  setTimeout(()=>$('#p1')?.focus(),100);
}
async function doPinSetup(){
  const a=$('#p1').value.trim(),b=$('#p2').value.trim();
  if(!/^\d{4,6}$/.test(a))return toast('PIN ต้องเป็นตัวเลข 4-6 หลัก');
  if(a!==b)return toast('PIN สองช่องไม่ตรงกัน');
  $('#pBtn').disabled=true;
  try{await call('setupPin',{pin:a});_pin=a;sessionStorage.setItem('kb_pin',a);(window._afterPin||teacherHome)()}
  catch(e){$('#pBtn').disabled=false;toast(e.message)}
}
function pinEnter(next){
  app.innerHTML=`<div class="wrap"><div class="pingate">
    <div class="ic">🔐</div><h2>ใส่ PIN ครู</h2><p>กรอกรหัสเพื่อเข้าโหมดครู</p>
    <div class="field"><input id="pin" class="pininput" type="password" inputmode="numeric" maxlength="6" placeholder="••••" onkeydown="if(event.key==='Enter')doPinEnter()"></div>
    <button class="btn primary block" id="pBtn" onclick="doPinEnter()">เข้าสู่โหมดครู</button>
    <button class="btn ghost block" style="margin-top:8px" onclick="go('')">กลับ</button>
    <p style="font-size:12px;color:var(--ink-soft);margin:14px 0 0">ลืม PIN? เปิด Apps Script แล้วรันฟังก์ชัน resetPinManually</p>
  </div></div>`;
  window._afterPin=next;
  setTimeout(()=>$('#pin')?.focus(),100);
}
async function doPinEnter(){
  const p=$('#pin').value.trim();if(!p)return toast('ใส่ PIN ก่อนนะ');
  $('#pBtn').disabled=true;
  try{
    const r=await call('verifyPin',{pin:p});
    if(!r.valid){$('#pBtn').disabled=false;return toast('PIN ไม่ถูกต้อง')}
    _pin=p;sessionStorage.setItem('kb_pin',p);(window._afterPin||teacherHome)();
  }catch(e){$('#pBtn').disabled=false;toast(e.message)}
}

/* ============================================================ TEACHER HOME */
async function teacherHome(){
  setWho('โหมดครู');loading();
  let data;try{data=await call('listBoards',{})}catch(e){return toast(e.message)}
  const boards=(data&&data.boards)?data.boards:[];
  let cards='';
  for(const b of boards){
    const total=b.roster||b.submitted||0;
    const pct=total?Math.min(100,Math.round(b.reviewed/total*100)):0;
    cards+=`<div class="bcard">
      <button class="cardmenu" onclick="event.stopPropagation();boardMenu('${b.id}','${esc(b.title).replace(/'/g,"\\'")}')">⋯</button>
      <button style="all:unset;cursor:pointer;display:block;width:100%" onclick="go('board/${b.id}')">
        <span class="tag">${esc(b.room||'ไม่ระบุห้อง')}</span><h3>${esc(b.title)}</h3>
        <div class="meta">ส่งแล้ว ${b.submitted} ชิ้น · ตรวจแล้ว ${b.reviewed}</div>
        <div class="prog"><i style="width:${pct}%"></i></div>
        <div class="pnum"><span>ความคืบหน้าการตรวจ</span><span>${pct}%</span></div>
      </button></div>`;
  }
  app.innerHTML=`<div class="wrap"><div class="page">
    <div class="phead"><div><h2>กระดานของฉัน</h2><div class="sub">การบ้านแต่ละชิ้นคือกระดานหนึ่งใบ</div></div></div>
    <div class="grid"><button class="newcard" onclick="go('new')"><span class="plus">+</span>สร้างการบ้านใหม่</button>${cards}</div>
    ${boards.length?'':'<div class="empty"><div class="big">📋</div>ยังไม่มีกระดาน เริ่มสร้างการบ้านแรกได้เลย</div>'}
  </div></div>`;
}
function boardMenu(id,title){
  modal(`<h3>${esc(title)}</h3>
    <button class="btn block" style="margin-bottom:8px" onclick="closeModal();go('board/${id}')">✏️ เปิดตรวจงาน</button>
    <button class="btn block" style="margin-bottom:8px" onclick="closeModal();go('share/${id}')">📤 แจกลิงก์ให้นักเรียน</button>
    <button class="btn block" style="margin-bottom:8px" onclick="closeModal();go('sum/${id}')">📊 ดูสรุปผล</button>
    <button class="btn danger block" onclick="confirmDeleteBoard('${id}','${esc(title).replace(/'/g,"\\'")}')">🗑 ลบกระดานนี้</button>`);
}
function confirmDeleteBoard(id,title){
  modal(`<h3>ลบ "${esc(title)}"?</h3>
    <p style="font-size:14px;color:var(--ink-soft);margin-top:-6px">จะลบงานนักเรียนและรูปทั้งหมดในกระดานนี้ — กู้คืนไม่ได้</p>
    <div class="row2"><button class="btn" onclick="closeModal()">ยกเลิก</button><button class="btn danger" id="delBtn" onclick="doDeleteBoard('${id}')">ลบถาวร</button></div>`);
}
async function doDeleteBoard(id){
  $('#delBtn').disabled=true;
  try{await call('deleteBoard',{board:id});closeModal();toast('ลบกระดานแล้ว');teacherHome()}
  catch(e){$('#delBtn').disabled=false;toast(e.message)}
}

/* ============================================================ TEACHER NEW */
function teacherNew(){
  setWho('โหมดครู');
  app.innerHTML=`<div class="wrap"><div class="page">
    <button class="back" onclick="go('t')">← กลับ</button><div class="phead"><h2>สร้างการบ้านใหม่</h2></div>
    <div style="max-width:460px">
      <div class="field"><label>ชื่อการบ้าน</label><input id="f_title" placeholder="เช่น ใบงานเศษส่วน บทที่ 3"></div>
      <div class="field"><label>ห้อง / ระดับชั้น</label><input id="f_room" placeholder="เช่น ม.1/2"></div>
      <div class="field"><label>จำนวนนักเรียนในห้อง</label><input id="f_roster" type="number" inputmode="numeric" value="35"></div>
      <div class="field"><div class="toggle" onclick="this.querySelector('.sw').classList.toggle('on')">
        <div class="lab">ให้นักเรียนเห็นงานของกัน<small>เปิด = เห็นงานเพื่อนทั้งห้อง · ปิด = เห็นเฉพาะงานตัวเอง</small></div><div class="sw" id="f_peer"></div></div></div>
      <button class="btn primary block" id="cbtn" onclick="createBoard()">สร้างกระดาน + รับลิงก์</button>
    </div></div></div>`;
  setTimeout(()=>$('#f_title')?.focus(),100);
}
async function createBoard(){
  const title=$('#f_title').value.trim();if(!title)return toast('ใส่ชื่อการบ้านก่อนนะ');
  $('#cbtn').disabled=true;
  try{
    const r=await call('createBoard',{title,room:$('#f_room').value.trim(),roster:parseInt($('#f_roster').value)||0,peer:$('#f_peer').classList.contains('on')});
    go('share/'+r.id);
  }catch(e){toast(e.message);$('#cbtn').disabled=false}
}

/* ============================================================ SHARE */
async function shareView(id){
  setWho('โหมดครู');
  const link=studentLink(id);
  app.innerHTML=`<div class="wrap"><div class="page">
    <button class="back" onclick="go('board/${id}')">← ไปกระดาน</button><div class="phead"><h2>แจกให้นักเรียน</h2></div>
    <div class="share" style="max-width:420px;margin:0 auto">
      <div class="qrbox"><img src="${qrURL(link)}" alt="QR code"></div>
      <div style="font-weight:600">ฉายโค้ดนี้หน้าห้อง ให้นักเรียนสแกน</div>
      <div style="font-size:13px;color:var(--ink-soft);margin-top:4px">หรือส่งลิงก์ในไลน์กลุ่มห้อง</div>
      <div class="linkrow"><input id="lk" value="${link}" readonly><button class="btn sm" onclick="copyLink()">คัดลอก</button></div>
      <div style="font-size:12.5px;color:var(--ink-soft);margin-top:10px">รหัสกระดาน: <b style="color:var(--chalk)">${id}</b></div>
      <button class="btn primary block" style="margin-top:18px" onclick="go('board/${id}')">ไปหน้าตรวจงาน</button>
    </div></div></div>`;
}
function copyLink(){const i=$('#lk');i.select();navigator.clipboard?.writeText(i.value);toast('คัดลอกลิงก์แล้ว')}

/* ============================================================ MARKING BOARD */
let _filter='all',_subsCache=[],_curBoard=null;
async function teacherBoard(id){
  setWho('โหมดครู');loading();_curBoard=id;
  let data;try{data=await call('getSubs',{board:id})}catch(e){toast(e.message);return go('t')}
  _subsCache=data.subs;
  const subs=data.subs;
  const counts={all:subs.length,wait:subs.filter(s=>s.status==='wait').length,done:subs.filter(s=>s.status!=='wait').length};
  let show=subs;
  if(_filter==='wait')show=subs.filter(s=>s.status==='wait');
  if(_filter==='done')show=subs.filter(s=>s.status!=='wait');
  const seg=(k,l)=>`<button class="${_filter===k?'on':''}" onclick="setFilter('${k}','${id}')">${l}</button>`;
  let cells='';
  show.forEach(s=>{
    const idx=subs.indexOf(s);
    const badge=s.status==='ok'?'<div class="badge ok">✓</div>':s.status==='no'?'<div class="badge no">✕</div>':s.status==='star'?'<div class="badge star">★</div>':'';
    const ring=s.status==='wait'?'<div class="pending-ring"></div>':'';
    cells+=`<button class="work" onclick="openViewer(${idx})">${ring}${badge}
      <img class="thumb" src="${s.img}" loading="lazy" referrerpolicy="no-referrer" alt="งานเลขที่ ${s.no}">
      <div class="wfoot"><span class="no">เลขที่ ${s.no}</span>
      ${s.score!=null&&s.score!==''?`<span class="scorechip">${esc(String(s.score))}</span>`:`<span class="nm">${esc(s.name||'')}</span>`}</div></button>`;
  });
  app.innerHTML=`<div class="wrap"><div class="page">
    <button class="back" onclick="go('t')">← กระดานทั้งหมด</button><div class="phead"><h2>ตรวจงาน</h2></div>
    <div class="toolbar"><div class="seg">${seg('all','ทั้งหมด '+counts.all)}${seg('wait','ยังไม่ตรวจ '+counts.wait)}${seg('done','ตรวจแล้ว '+counts.done)}</div>
      <div style="flex:1"></div><button class="btn sm" onclick="go('share/${id}')">📤 แจกลิงก์</button><button class="btn sm" onclick="go('sum/${id}')">📊 สรุปผล</button></div>
    ${show.length?`<div class="mgrid">${cells}</div>`:`<div class="empty"><div class="big">🪧</div>${subs.length?'ไม่มีงานในหมวดนี้':'ยังไม่มีนักเรียนส่งงาน — แจกลิงก์ให้ห้องได้เลย'}</div>`}
  </div></div>`;
}
function setFilter(k,id){_filter=k;teacherBoard(id)}

/* ============================================================ VIEWER (zoom/pan/pinch) */
let _vIdx=0,_vSubs=[];
let _zoom=1,_panX=0,_panY=0;
function openViewer(idx){_vSubs=_subsCache;_vIdx=idx;renderViewer()}
function renderViewer(){
  const s=_vSubs[_vIdx];
  resetZoom();
  const stamp=k=>`<button class="stamp ${k} ${s.status===k?'on':''}" onclick="setStamp('${k}')">${k==='ok'?'✓ ถูก':k==='no'?'✕ ผิด':'★ ดีเด่น'}</button>`;
  let el=document.getElementById('viewer');
  if(!el){el=document.createElement('div');el.id='viewer';el.className='viewer';document.body.appendChild(el)}
  el.innerHTML=`<div class="vtop">
      <div><div class="vname">เลขที่ ${s.no}${s.name?' · '+esc(s.name):''}</div><div class="vsub">${_vIdx+1} / ${_vSubs.length}</div></div>
      <div class="vtools">
        <button class="vbtn" onclick="resetZoom();applyZoom()" title="รีเซ็ตซูม">⊙</button>
        <button class="vbtn danger" onclick="confirmDeleteSub('${s.id}')" title="ลบงานนี้">🗑</button>
        <button class="vbtn" onclick="closeViewer()" title="ปิด">✕</button>
      </div></div>
    <div class="vstage" id="vstage">
      <button class="varrow l" ${_vIdx===0?'disabled':''} onclick="vNav(-1)">‹</button>
      <img id="vimg" src="${s.img}" referrerpolicy="no-referrer" alt="งานเลขที่ ${s.no}" draggable="false">
      <button class="varrow r" ${_vIdx===_vSubs.length-1?'disabled':''} onclick="vNav(1)">›</button>
      <div class="vhint">แตะสองครั้งเพื่อซูม · ลากเพื่อเลื่อน · บีบนิ้วซูมได้</div>
    </div>
    <div class="vmark"><div class="stamps">${stamp('ok')}${stamp('no')}${stamp('star')}</div>
      <div class="markrow"><div class="sc field" style="margin:0"><input id="v_score" inputmode="decimal" placeholder="คะแนน" value="${s.score!=null?esc(String(s.score)):''}"></div>
      <textarea id="v_comment" placeholder="คอมเมนต์ถึงนักเรียน (ไม่บังคับ)">${esc(s.comment||'')}</textarea></div>
      <div class="vsavebar"><button class="btn ghost" onclick="closeViewer()">ปิด</button><button class="btn primary block" id="saveBtn" onclick="saveMark()">บันทึก & ถัดไป →</button></div></div>`;
  document.body.style.overflow='hidden';
  attachZoom();
}
function setStamp(k){const s=_vSubs[_vIdx];collectInputs();s.status=s.status===k?'wait':k;renderViewer()}
function collectInputs(){const sc=document.getElementById('v_score'),cm=document.getElementById('v_comment'),s=_vSubs[_vIdx];if(sc)s.score=sc.value;if(cm)s.comment=cm.value}
function vNav(d){collectInputs();const n=_vIdx+d;if(n<0||n>=_vSubs.length)return;_vIdx=n;renderViewer()}
async function saveMark(){
  collectInputs();const s=_vSubs[_vIdx];
  if(s.status==='wait'&&s.score!==''&&s.score!=null)s.status='ok';
  $('#saveBtn').disabled=true;
  try{await call('gradeSub',{id:s.id,status:s.status,score:s.score,comment:s.comment});toast('บันทึกแล้ว')}
  catch(e){$('#saveBtn').disabled=false;return toast(e.message)}
  if(_vIdx<_vSubs.length-1){_vIdx++;renderViewer()}else closeViewer();
}
function closeViewer(){const el=document.getElementById('viewer');if(el)el.remove();document.body.style.overflow='';if(_curBoard)teacherBoard(_curBoard)}
function confirmDeleteSub(sid){
  modal(`<h3>ลบงานชิ้นนี้?</h3><p style="font-size:14px;color:var(--ink-soft);margin-top:-6px">นักเรียนจะต้องส่งใหม่ — กู้คืนรูปไม่ได้</p>
    <div class="row2"><button class="btn" onclick="closeModal()">ยกเลิก</button><button class="btn danger" id="dsBtn" onclick="doDeleteSub('${sid}')">ลบ</button></div>`);
}
async function doDeleteSub(sid){
  $('#dsBtn').disabled=true;
  try{await call('deleteSub',{id:sid});closeModal();
    const i=_vSubs.findIndex(x=>x.id===sid);if(i>=0)_vSubs.splice(i,1);
    if(_vSubs.length===0){closeViewer();return}
    if(_vIdx>=_vSubs.length)_vIdx=_vSubs.length-1;
    _subsCache=_vSubs;renderViewer();toast('ลบแล้ว');
  }catch(e){$('#dsBtn').disabled=false;toast(e.message)}
}
/* --- zoom/pan/pinch engine --- */
function resetZoom(){_zoom=1;_panX=0;_panY=0}
function applyZoom(){const img=document.getElementById('vimg');if(img)img.style.transform=`translate(${_panX}px,${_panY}px) scale(${_zoom})`}
function attachZoom(){
  const stage=document.getElementById('vstage'),img=document.getElementById('vimg');
  if(!stage||!img)return;
  let lastTap=0,dragging=false,sx=0,sy=0,startPanX=0,startPanY=0;
  let pinchStart=0,zoomStart=1;
  const pts=new Map();
  img.addEventListener('dblclick',e=>{e.preventDefault();_zoom=_zoom>1?1:2.2;if(_zoom===1)resetZoom();applyZoom()});
  stage.addEventListener('pointerdown',e=>{
    pts.set(e.pointerId,{x:e.clientX,y:e.clientY});
    if(pts.size===1){dragging=true;sx=e.clientX;sy=e.clientY;startPanX=_panX;startPanY=_panY;
      const now=Date.now();if(now-lastTap<300){_zoom=_zoom>1?1:2.2;if(_zoom===1)resetZoom();applyZoom();dragging=false}lastTap=now;
    }else if(pts.size===2){dragging=false;const a=[...pts.values()];pinchStart=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);zoomStart=_zoom}
  });
  stage.addEventListener('pointermove',e=>{
    if(!pts.has(e.pointerId))return;
    pts.set(e.pointerId,{x:e.clientX,y:e.clientY});
    if(pts.size===2){const a=[...pts.values()];const d=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);
      _zoom=Math.min(5,Math.max(1,zoomStart*(d/pinchStart)));if(_zoom===1)resetZoom();applyZoom();
    }else if(dragging&&_zoom>1){_panX=startPanX+(e.clientX-sx);_panY=startPanY+(e.clientY-sy);applyZoom()}
  });
  const up=e=>{pts.delete(e.pointerId);if(pts.size<2)pinchStart=0;if(pts.size===0)dragging=false};
  stage.addEventListener('pointerup',up);stage.addEventListener('pointercancel',up);
}

/* ============================================================ SUMMARY */
async function summaryView(id){
  setWho('โหมดครู');loading();
  let data;try{data=await call('getSubs',{board:id})}catch(e){return toast(e.message)}
  const subs=data.subs;
  const scored=subs.map(s=>parseFloat(s.score)).filter(n=>!isNaN(n));
  const avg=scored.length?(scored.reduce((a,b)=>a+b,0)/scored.length).toFixed(1):'–';
  const mx=scored.length?Math.max(...scored):'–',mn=scored.length?Math.min(...scored):'–';
  const done=subs.filter(s=>s.status!=='wait').length;
  let rows='';subs.forEach(s=>{rows+=`<tr><td>${s.no}</td><td>${esc(s.name||'–')}</td><td>${s.score!=null&&s.score!==''?esc(String(s.score)):'–'}</td><td>${s.status!=='wait'?'<span class="st-chip done">ตรวจแล้ว</span>':'<span class="st-chip wait">รอตรวจ</span>'}</td></tr>`});
  app.innerHTML=`<div class="wrap"><div class="page"><button class="back" onclick="go('board/${id}')">← กลับกระดาน</button><div class="phead"><h2>สรุปผล</h2></div>
    <div class="stats"><div class="stat accent"><div class="v">${subs.length}</div><div class="k">ส่งแล้ว</div></div>
      <div class="stat"><div class="v">${done}</div><div class="k">ตรวจแล้ว</div></div>
      <div class="stat accent"><div class="v">${avg}</div><div class="k">คะแนนเฉลี่ย</div></div>
      <div class="stat"><div class="v">${mx}/${mn}</div><div class="k">สูงสุด/ต่ำสุด</div></div></div>
    <div class="toolbar"><div style="flex:1"></div><button class="btn sm" onclick="exportCSV()">⬇ ดาวน์โหลด CSV</button></div>
    <table><thead><tr><th>เลขที่</th><th>ชื่อ</th><th>คะแนน</th><th>สถานะ</th></tr></thead><tbody>${rows||'<tr><td colspan=4 style="text-align:center;color:var(--ink-soft)">ยังไม่มีข้อมูล</td></tr>'}</tbody></table>
  </div></div>`;
  window._sumSubs=subs;
}
function exportCSV(){
  const subs=window._sumSubs||[];
  let csv='\uFEFFเลขที่,ชื่อ,คะแนน,สถานะ,คอมเมนต์\n';
  subs.forEach(s=>{csv+=`${s.no},"${(s.name||'').replace(/"/g,'""')}",${s.score!=null?s.score:''},${s.status!=='wait'?'ตรวจแล้ว':'รอตรวจ'},"${(s.comment||'').replace(/"/g,'""')}"\n`});
  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download='สรุปผล.csv';a.click();toast('ดาวน์โหลดแล้ว');
}

/* ============================================================ STUDENT SUBMIT */
let _pendingImg=null,_curStudentBoard=null;
async function studentSubmit(id){
  setWho('');loading();_curStudentBoard=id;
  let r;try{r=await call('getBoardPublic',{board:id})}catch(e){
    app.innerHTML=`<div class="wrap"><div class="empty" style="padding-top:80px"><div class="big">🔍</div>ไม่พบกระดานนี้<br><span style="font-size:13px">ลองเช็ครหัสกระดานกับครูอีกครั้ง</span><br><button class="btn sm" style="margin-top:14px" onclick="go('')">กลับหน้าแรก</button></div></div>`;return}
  const b=r.board;_pendingImg=null;
  app.innerHTML=`<div class="wrap"><div class="page"><div class="scard">
    <div class="stask"><div class="tag">ส่งการบ้าน</div><h2>${esc(b.title)}</h2><div class="rm">${esc(b.room||'')}</div></div>
    <div class="field"><label>เลขที่ของเธอ</label><input id="s_no" type="number" inputmode="numeric" placeholder="เช่น 12"></div>
    <div class="field"><label>ชื่อ (ไม่บังคับ)</label><input id="s_name" placeholder="ชื่อเล่นก็ได้"></div>
    <div class="field"><label>รูปงานของเธอ</label>
      <label class="drop" id="drop"><input type="file" accept="image/*" capture="environment" style="display:none" onchange="pickImg(this)">
      <div id="dropInner"><div class="cam">📷</div>แตะเพื่อถ่ายรูป หรือเลือกจากเครื่อง</div></label></div>
    <button class="btn primary block" id="subBtn" onclick="submitWork('${id}')">ส่งงาน</button>
    <button class="btn ghost block" style="margin-top:8px" onclick="go('r/${id}')">ดูผลการตรวจของฉัน</button>
  </div>${b.peer?'<div id="peerZone"></div>':''}</div></div>`;
  if(b.peer)loadPeer(id);
}
async function pickImg(input){
  const f=input.files[0];if(!f)return;
  $('#dropInner').innerHTML='<div style="color:var(--ink-soft)">กำลังย่อรูป…</div>';
  try{
    const{dataUrl,origKB,newKB}=await compress(f);_pendingImg=dataUrl;
    $('#drop').classList.add('has');
    $('#dropInner').innerHTML=`<img class="preview" src="${dataUrl}"><div class="shrink-note">✓ ย่อรูปแล้ว ${origKB}KB → ${newKB}KB · แตะเพื่อเปลี่ยนรูป</div>`;
  }catch(e){$('#dropInner').innerHTML='<div class="cam">📷</div>เปิดรูปไม่ได้ ลองใหม่อีกครั้ง';toast(e.message)}
}
async function submitWork(id){
  const no=$('#s_no').value.trim();if(!no)return toast('ใส่เลขที่ก่อนนะ');
  if(parseInt(no)<1)return toast('เลขที่ต้องมากกว่า 0');
  if(!_pendingImg)return toast('ยังไม่ได้แนบรูปงาน');
  $('#subBtn').disabled=true;$('#subBtn').textContent='กำลังส่ง…';
  try{await call('submitWork',{board:id,no:parseInt(no),name:$('#s_name').value.trim(),image:_pendingImg})}
  catch(e){$('#subBtn').disabled=false;$('#subBtn').textContent='ส่งงาน';return toast(e.message)}
  app.innerHTML=`<div class="wrap"><div class="page"><div class="result">
    <div style="font-size:60px;margin-top:30px">🎉</div><h2 style="margin:6px 0">ส่งงานเรียบร้อย!</h2>
    <p style="color:var(--ink-soft)">เลขที่ ${esc(no)}</p>
    <button class="btn primary block" style="margin-top:20px;max-width:300px;margin-inline:auto" onclick="go('r/${id}')">ดูผลการตรวจ (เมื่อครูตรวจแล้ว)</button>
    <button class="btn ghost block" style="margin-top:8px;max-width:300px;margin-inline:auto" onclick="go('s/${id}')">ส่งใหม่ / ส่งคนอื่น</button>
  </div></div></div>`;
}
async function loadPeer(id){
  const zone=$('#peerZone');if(!zone)return;
  zone.innerHTML='<div class="peerwrap"><h3>👀 งานของเพื่อนในห้อง</h3><div class="spin" style="margin:10px auto"></div></div>';
  let data;try{data=await call('getPeer',{board:id})}catch(e){zone.innerHTML='';return}
  if(!data.peer||!data.subs.length){
    zone.innerHTML=`<div class="peerwrap"><h3>👀 งานของเพื่อนในห้อง</h3><div class="empty" style="padding:24px"><div class="big">🪧</div>ยังไม่มีเพื่อนส่งงาน เป็นคนแรกเลย!</div></div>`;return;
  }
  let cells='';
  data.subs.forEach(s=>{
    cells+=`<div class="peercell"><img src="${s.img}" loading="lazy" referrerpolicy="no-referrer" alt="งานเลขที่ ${s.no}"><div class="pno">เลขที่ ${s.no}</div></div>`;
  });
  zone.innerHTML=`<div class="peerwrap"><h3>👀 งานของเพื่อนในห้อง (${data.subs.length})</h3><div class="peergrid">${cells}</div></div>`;
}

/* ============================================================ STUDENT RESULT */
async function studentResult(id){
  setWho('');
  app.innerHTML=`<div class="wrap"><div class="page"><div class="result">
    <button class="back" onclick="go('s/${id}')">← กลับไปส่งงาน</button>
    <div class="phead" style="justify-content:center"><h2>ดูผลการตรวจ</h2></div>
    <div class="field" style="max-width:240px;margin:0 auto"><label>ใส่เลขที่ของเธอ</label><input id="r_no" type="number" inputmode="numeric" placeholder="เลขที่" onkeydown="if(event.key==='Enter')showResult('${id}')"></div>
    <button class="btn primary" style="margin-top:6px" onclick="showResult('${id}')">ดูผล</button><div id="rOut"></div></div></div></div>`;
  setTimeout(()=>$('#r_no')?.focus(),100);
}
async function showResult(id){
  const no=$('#r_no').value.trim();if(!no)return toast('ใส่เลขที่ก่อนนะ');
  const out=$('#rOut');out.innerHTML='<div class="spin"></div>';
  let r;try{r=await call('getResult',{board:id,no:parseInt(no)})}catch(e){out.innerHTML=`<div class="empty" style="padding:30px"><div class="big">🤔</div>${esc(e.message)}</div>`;return}
  const s=r.sub;
  if(s.status==='wait'){out.innerHTML=`<div class="rsheet"><img src="${s.img}" referrerpolicy="no-referrer"><div style="padding:18px"><div style="font-size:34px">⏳</div><div style="font-weight:600">ครูยังไม่ได้ตรวจงานนี้</div><div style="font-size:13px;color:var(--ink-soft)">กลับมาดูใหม่ทีหลังนะ</div></div></div>`;return}
  const stamp=s.status==='ok'?'✅':s.status==='no'?'❌':'⭐';
  out.innerHTML=`<div class="rsheet"><img src="${s.img}" referrerpolicy="no-referrer"><div class="rstamp">${stamp}</div>
    ${s.score!=null&&s.score!==''?`<div class="rscore">${esc(String(s.score))} คะแนน</div>`:''}
    ${s.comment?`<div class="rcomment">${esc(s.comment)}</div>`:'<div style="height:14px"></div>'}</div>`;
}

/* ============================================================ MODAL */
function modal(html){let m=document.createElement('div');m.className='modal-bg';m.id='mbg';m.onclick=e=>{if(e.target===m)closeModal()};m.innerHTML=`<div class="modal">${html}</div>`;document.body.appendChild(m)}
function closeModal(){const m=$('#mbg');if(m)m.remove()}

/* ---- boot ---- */
route();
</script>
</body>
</html>
รันติดตั้งครั้งเดียว (สำคัญมาก!)
⚠️ ห้ามข้ามขั้นนี้
ถ้าข้าม เว็บจะ "หมุนค้าง" เปิดไม่ได้ตอนนักเรียนเข้ามาใช้ — ขั้นนี้เชื่อมต่อฐานข้อมูลให้พร้อม
  1. ด้านบนของหน้า Apps Script มีแถบเลือกฟังก์ชัน (ข้างปุ่ม ▶️ Run) — กดเลือก setupKruBoard
  2. กดปุ่ม ▶️ Run
  3. ครั้งแรกจะขออนุญาตสิทธิ์ → กด ตรวจสอบสิทธิ์ → เลือกบัญชี → ถ้าเจอ "Google ยังไม่ได้ยืนยันแอปนี้" กด ขั้นสูง (Advanced)ไปที่ KruBoard (ไม่ปลอดภัย)อนุญาต
  4. รอจนข้างล่างขึ้นข้อความว่า "✅ ติดตั้งเรียบร้อย" (ดูใน Execution log) — ถือว่าผ่าน
💡 ขั้นนี้ทำอะไร
เชื่อมต่อฐานข้อมูล (Sheet) + สร้างชีตเก็บข้อมูล + สร้างโฟลเดอร์เก็บรูปใน Drive ให้พร้อมใช้ทันที ก่อนเปิดเป็นเว็บจริง
4 เผยแพร่เป็นเว็บ (Deploy)
⚠️ ขั้นสำคัญที่สุด
ตั้งค่าผิดตรงนี้ นักเรียนจะส่งงานไม่ได้ — ทำตามตารางให้เป๊ะ
  1. มุมขวาบน กดปุ่มสีน้ำเงิน ทำให้ใช้งานได้ (Deploy) แล้วเลือก การทำให้ใช้งานได้ใหม่
  2. กดรูปเฟือง ⚙️ ข้างคำว่า "เลือกประเภท" แล้วเลือก เว็บแอป (Web app)
  3. ตั้งค่าตามตารางนี้:
ช่องตั้งเป็น
คำอธิบาย (Description)KruBoard (พิมพ์อะไรก็ได้)
ดำเนินการในชื่อ (Execute as)ฉัน (Me) ← ชื่อบัญชีครู
ผู้มีสิทธิ์เข้าถึง (Who has access)ทุกคน (Anyone) ← ต้องเป็นอันนี้!
  1. กด ทำให้ใช้งานได้ (Deploy)
5 อนุญาตสิทธิ์ (ทำครั้งเดียว)

ครั้งแรก Google จะถามขออนุญาตให้โปรแกรมเข้าถึง Sheet และ Drive ของครู

  1. กด ตรวจสอบสิทธิ์ (Authorize access)
  2. เลือกบัญชี Google ของครู
  3. ถ้าเจอหน้าจอเตือน "Google ยังไม่ได้ยืนยันแอปนี้" ให้กด ขั้นสูง (Advanced) แล้วกด ไปที่ KruBoard (ไม่ปลอดภัย)
  4. กด อนุญาต (Allow)
ไม่ต้องกังวล
คำว่า "ไม่ปลอดภัย (unsafe)" ขึ้นกับทุกแอปที่เขียนเอง เพราะยังไม่ได้ส่งให้ Google ตรวจ — แอปนี้เข้าถึงเฉพาะไฟล์ของครูเท่านั้น ไม่มีใครอื่นเห็นข้อมูล
6 รับลิงก์ แล้วเริ่มใช้!
  1. หลัง Deploy เสร็จ จะมีกล่องขึ้นมาพร้อม URL ของเว็บแอป (ลิงก์ยาวๆ ขึ้นต้นด้วย https://script.google.com/...)
  2. คัดลอกลิงก์นี้เก็บไว้ — นี่คือลิงก์เว็บ KruBoard ของครู (บุ๊กมาร์กไว้เลย)
  3. เปิดลิงก์ → ครั้งแรกจะให้ ตั้ง PIN ครู 4-6 หลัก (ไว้ป้องกันหน้าตรวจงาน จำให้ดี!)
  4. เสร็จแล้ว! เริ่มสร้างการบ้านได้เลย

🎓 วิธีใช้งานประจำวัน

👩‍🏫 ฝั่งครู

  1. เปิดลิงก์ → กด "ฉันเป็นครู" → ใส่ PIN
  2. กด "สร้างการบ้านใหม่" ตั้งชื่อ เลือกห้อง
  3. ได้ QR + ลิงก์ → ฉายหน้าห้อง หรือส่งไลน์กลุ่ม
  4. แตะรูปเพื่อตรวจ ให้ ✓ / ✗ / ★ ใส่คะแนน + คอมเมนต์
  5. ดูสรุปผล โหลด CSV เข้าโปรแกรมคะแนน

🧑‍🎓 ฝั่งนักเรียน

  1. สแกน QR หรือเปิดลิงก์จากครู
  2. ใส่เลขที่ → ถ่ายรูปงาน → กดส่ง
  3. ระบบย่อรูปให้อัตโนมัติ ไม่กินเน็ต
  4. กลับมาดูผลการตรวจได้ทีหลังด้วยเลขที่

❓ ปัญหาที่พบบ่อย

เปิดเว็บแล้วหมุนค้าง เข้าไม่ได้?
เกือบทุกครั้งเกิดจากยังไม่ได้รันขั้นติดตั้ง — กลับไปที่ Apps Script เลือกฟังก์ชัน setupKruBoard แล้วกด ▶️ Run หนึ่งครั้ง จากนั้น Deploy ใหม่ (เลือก New version) แล้วเปิดเว็บอีกที
ขึ้น error "No HTML file named Index"?
ชื่อไฟล์ HTML ตั้งผิด ต้องเป็น Index เป๊ะ (I ตัวใหญ่ ไม่มี .html ต่อท้าย) — กดจุดสามจุดข้างชื่อไฟล์ → เปลี่ยนชื่อ (Rename) ให้ถูก แล้ว Deploy ใหม่
ลืม PIN ครู?
เปิดหน้า Apps Script → เลือกฟังก์ชัน resetPinManually จากเมนูบนสุด → กด ▶️ รัน → กลับไปเปิดเว็บตั้ง PIN ใหม่ได้เลย
แก้โค้ดแล้วเว็บไม่อัปเดต?
ต้อง Deploy ใหม่: Deployจัดการการทำให้ใช้งานได้ → กดดินสอ ✏️ → ช่องเวอร์ชันเลือก "ใหม่ (New version)" → Deploy (ใช้ลิงก์เดิม ไม่ต้องแจกใหม่)
นักเรียนเปิดลิงก์แล้วเจอหน้า login Google?
แปลว่าตอน Deploy ตั้ง "ผู้มีสิทธิ์เข้าถึง" ไม่ใช่ "ทุกคน (Anyone)" → กลับไปขั้นที่ 4 แก้เป็น Anyone แล้ว Deploy ใหม่
รูปนักเรียนไม่ขึ้น?
รูปเก็บใน Google Drive โฟลเดอร์ชื่อ "KruBoard (รูปงานนักเรียน)" — อย่าลบหรือย้ายโฟลเดอร์นี้ ถ้าเพิ่งส่งให้รอสักครู่
อยากแจกครูคนอื่นใช้ ทำยังไง?
ส่งบทความนี้ให้เลย! ครูคนนั้นทำสำเนาโค้ดไปติดตั้งเองตามขั้นตอนนี้ — ข้อมูลของแต่ละคนจะแยกกันสมบูรณ์ (1 คน = 1 ชุด = ข้อมูลส่วนตัว)
🎉

ลองเอาไปใช้ในห้องเรียนได้เลย

KruBoard เป็นเครื่องมือฟรีจากครูติ ที่อยากให้ครูไทยทุกคนมีระบบเก็บงานนักเรียนที่ดี ถ้าชอบ ฝากแชร์บทความนี้ให้เพื่อนครูด้วยนะครับ 💛

KruBoard · Google Apps Script edition · แจกฟรีโดย ครูติ (kru-ti.com)


แชร์: 💬 LINE 📘 Facebook
อ่านบทความอื่น → ดูแอปการสอนทั้งหมด →