diff --git a/KPP.png b/KPP.png index ce71511..32b1275 100644 Binary files a/KPP.png and b/KPP.png differ diff --git a/config.js b/config.js new file mode 100644 index 0000000..f39b2ec --- /dev/null +++ b/config.js @@ -0,0 +1,6 @@ +// CONFIGURATION +const SUPABASE_URL = 'https://gfikiopjopxhogrhpcpx.supabase.co'; +const SUPABASE_KEY = 'sb_publishable_6pccrfn1Ng_FXHugztRsmw__kW_TtAL'; +const GOOGLE_CLIENT_ID = '292294403643-n756epva6fet5p7clkao3r2damoeai0r.apps.googleusercontent.com'; + +const _sb = supabase.createClient(SUPABASE_URL, SUPABASE_KEY); diff --git a/dna.js b/dna.js new file mode 100644 index 0000000..3f12b72 --- /dev/null +++ b/dna.js @@ -0,0 +1,210 @@ +(function () { + const canvas = document.getElementById('dna-canvas'); + if (!canvas) return; + + const wrap = canvas.parentElement; + const ctx = canvas.getContext('2d'); + + let animationId = null; + let t = 0; + let isRunning = false; + + function resizeCanvas() { + const rect = wrap.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + } + + function render() { + const rect = wrap.getBoundingClientRect(); + const W = rect.width; + const H = rect.height; + const cx = W / 2; + + ctx.clearRect(0, 0, W, H); + + const DOTS = Math.max(16, Math.floor(H / 42)); + const SPACING = H / Math.max(1, DOTS - 1); + const AMPLITUDE = Math.min(W * 0.34, 38); + const PERIOD = H * 0.65; + + const dots = []; + + for (let i = 0; i < DOTS; i++) { + const y = i * SPACING; + const angle = (y / PERIOD) * Math.PI * 2 + t; + + dots.push({ + y, + x1: cx + Math.cos(angle) * AMPLITUDE, + z1: Math.sin(angle), + x2: cx + Math.cos(angle + Math.PI) * AMPLITUDE, + z2: Math.sin(angle + Math.PI) + }); + } + + for (const { y, x1, x2, z1, z2 } of dots) { + if (Math.abs(z1 - z2) < 0.4) { + const a = 0.08 + (1 - Math.abs(z1 - z2) / 0.4) * 0.12; + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.strokeStyle = `rgba(180,180,210,${a})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + + const all = []; + for (const { y, x1, x2, z1, z2 } of dots) { + all.push({ x: x1, y, z: z1, strand: 1 }); + all.push({ x: x2, y, z: z2, strand: 2 }); + } + + all.sort((a, b) => a.z - b.z); + + for (const d of all) { + const dep = (d.z + 1) / 2; + const radius = 4 + dep * 7; + + ctx.beginPath(); + ctx.arc(d.x, d.y, radius, 0, Math.PI * 2); + + ctx.fillStyle = d.strand === 1 + ? `rgba(224,224,240,${0.2 + dep * 0.75})` + : `rgba(255,255,255,${0.12 + dep * 0.5})`; + + ctx.shadowColor = d.strand === 1 + ? 'rgba(224,224,240,0.55)' + : 'rgba(255,255,255,0.35)'; + ctx.shadowBlur = dep * 12; + + ctx.fill(); + ctx.shadowBlur = 0; + } + + t += 0.018; + animationId = requestAnimationFrame(render); + } + + function start() { + if (isRunning) return; + isRunning = true; + resizeCanvas(); + render(); + } + + function stop() { + if (!isRunning) return; + cancelAnimationFrame(animationId); + animationId = null; + isRunning = false; + } + + const mediaReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + function handleMotionPreference() { + if (mediaReducedMotion.matches) { + stop(); + resizeCanvas(); + renderStaticFrame(); + } else { + start(); + } + } + + function renderStaticFrame() { + const rect = wrap.getBoundingClientRect(); + const W = rect.width; + const H = rect.height; + const cx = W / 2; + + ctx.clearRect(0, 0, W, H); + + const DOTS = Math.max(16, Math.floor(H / 42)); + const SPACING = H / Math.max(1, DOTS - 1); + const AMPLITUDE = Math.min(W * 0.34, 38); + const PERIOD = H * 0.65; + + const dots = []; + + for (let i = 0; i < DOTS; i++) { + const y = i * SPACING; + const angle = (y / PERIOD) * Math.PI * 2; + + dots.push({ + y, + x1: cx + Math.cos(angle) * AMPLITUDE, + z1: Math.sin(angle), + x2: cx + Math.cos(angle + Math.PI) * AMPLITUDE, + z2: Math.sin(angle + Math.PI) + }); + } + + for (const { y, x1, x2, z1, z2 } of dots) { + if (Math.abs(z1 - z2) < 0.4) { + const a = 0.08 + (1 - Math.abs(z1 - z2) / 0.4) * 0.12; + ctx.beginPath(); + ctx.moveTo(x1, y); + ctx.lineTo(x2, y); + ctx.strokeStyle = `rgba(180,180,210,${a})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + + const all = []; + for (const { y, x1, x2, z1, z2 } of dots) { + all.push({ x: x1, y, z: z1, strand: 1 }); + all.push({ x: x2, y, z: z2, strand: 2 }); + } + + all.sort((a, b) => a.z - b.z); + + for (const d of all) { + const dep = (d.z + 1) / 2; + const radius = 4 + dep * 7; + + ctx.beginPath(); + ctx.arc(d.x, d.y, radius, 0, Math.PI * 2); + + ctx.fillStyle = d.strand === 1 + ? `rgba(224,224,240,${0.2 + dep * 0.75})` + : `rgba(255,255,255,${0.12 + dep * 0.5})`; + + ctx.fill(); + } + } + + let resizeTimer; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + resizeCanvas(); + }, 100); + }); + + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stop(); + } else if (!mediaReducedMotion.matches) { + start(); + } + }); + + if (typeof mediaReducedMotion.addEventListener === 'function') { + mediaReducedMotion.addEventListener('change', handleMotionPreference); + } else if (typeof mediaReducedMotion.addListener === 'function') { + mediaReducedMotion.addListener(handleMotionPreference); + } + + handleMotionPreference(); +})(); diff --git a/index.html b/index.html index c59cd5d..bd2d86c 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ระบบจัดการชั้นเรียน + + @@ -13,392 +15,16 @@ - + + +
-
+
+ +
@@ -432,7 +58,7 @@
-

🧬 ตรวจสอบสถานะการส่งงาน

+

ตรวจสอบสถานะการส่งงาน

ใส่รหัสนักเรียนเพื่อดูคะแนนและสถานะงานทั้งหมด

@@ -704,7 +330,7 @@

🧬 ตรวจสอบสถานะการส่งงาน

👥 รายชื่อนักเรียน
- + @@ -888,1548 +514,6 @@

🧬 ตรวจสอบสถานะการส่งงาน

- - - + diff --git a/script.js b/script.js new file mode 100644 index 0000000..a11f5f6 --- /dev/null +++ b/script.js @@ -0,0 +1,1576 @@ +let googleAccessToken = null; +let isAdmin = false; +let currentUser = null; +let students = []; +let assignments = []; +let gradingRows = []; +let attendanceRows = []; +let adminInitialized = false; // FIX #3: guard against double-init + +let html5QrCode = null, scanning = false, lastScannedText = '', lastScannedAt = 0; +let attendanceQr = null, attendanceScanning = false, lastAttendanceScan = '', lastAttendanceScanAt = 0; +let scorePieChart = null; + +// ─── UI HELPERS ──────────────────────────────────────────── +function $(id) { return document.getElementById(id); } + +function showToast(msg, type = 'info') { + const icons = { success:'✅', error:'❌', info:'ℹ️', warn:'⚠️' }; + const el = document.createElement('div'); + el.className = 'toast ' + type; + el.innerHTML = `${icons[type]||'ℹ️'}${msg}`; + $('toasts').appendChild(el); + // FIX #10: fade out before remove + setTimeout(() => { el.style.transition = 'opacity 0.4s'; el.style.opacity = '0'; }, 3400); + setTimeout(() => el.remove(), 3800); +} + +function openModal(id) { $(id).classList.add('open'); } +function closeModal(id) { $(id).classList.remove('open'); } + +document.querySelectorAll('.modal-overlay').forEach(el => { + el.addEventListener('click', e => { if (e.target === el && el.id !== 'm-login') closeModal(el.id); }); +}); + +function setSyncProgress(pct, msg) { + const overlay = $('sync-overlay'); + if (pct <= 0) { overlay.classList.remove('open'); return; } + overlay.classList.add('open'); + $('sync-progress-text').textContent = msg || 'กำลังซิงค์...'; + $('sync-progress-bar').style.width = pct + '%'; +} + +// ─── NAVIGATION ──────────────────────────────────────────── +function showPage(name) { + stopQrScanner(); + stopAttendanceScanner(); + if (name === 'admin' && !isAdmin) { openModal('m-login'); return; } + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); + $('page-' + name).classList.add('active'); + $('nav-' + name).classList.add('active'); + if (name === 'admin' && isAdmin) initAdmin(); +} + +// ─── LOGIN / LOGOUT ──────────────────────────────────────── +async function doLogin() { + const email = $('l-user').value.trim(); + const pass = $('l-pass').value; + const btn = $('login-btn'); + $('l-err').style.display = 'none'; + btn.disabled = true; btn.textContent = 'กำลังยืนยันตัวตน...'; + try { + const { data, error } = await _sb.auth.signInWithPassword({ email, password: pass }); + if (error) throw error; + isAdmin = true; currentUser = data.user; + adminInitialized = false; // FIX #3: allow fresh init after login + closeModal('m-login'); + showToast('เข้าสู่ระบบสำเร็จ!', 'success'); + showPage('admin'); + } catch (e) { + $('l-err').textContent = '❌ ' + (e.message === 'Invalid login credentials' ? 'อีเมลหรือรหัสผ่านผิด' : e.message); + $('l-err').style.display = 'block'; + } finally { + btn.disabled = false; btn.textContent = 'เข้าสู่ระบบ'; + } +} + +async function doLogout() { + if (!confirm('คุณต้องการออกจากระบบใช่หรือไม่?')) return; + await _sb.auth.signOut(); + isAdmin = false; currentUser = null; adminInitialized = false; + $('nav-logout').style.display = 'none'; + $('cfg-dot').className = 'cfg-dot'; + showPage('status'); + location.reload(); +} + +// ─── SUPABASE HELPERS ────────────────────────────────────── +const gasCall = async (fnName, ...args) => { + if (fnName === 'getStudents') { + const { data, error } = await _sb.from('students').select('*').order('classroom').order('seat_no'); + if (error) throw error; return data || []; + } + if (fnName === 'getStudentById') { + const { data } = await _sb.from('students').select('*').eq('id', args[0]).maybeSingle(); return data; + } + if (fnName === 'upsertStudent') { + const { data, error } = await _sb.from('students').upsert(args[0], { onConflict: 'id' }).select(); + if (error) throw error; return data; + } + if (fnName === 'upsertStudents') { + const { data, error } = await _sb.from('students').upsert(args[0], { onConflict: 'id' }).select(); + if (error) throw error; return data; + } + if (fnName === 'deleteStudent') { + const { error } = await _sb.from('students').delete().eq('id', args[0]); + if (error) throw error; return true; + } + if (fnName === 'updateBehaviorScore') { + let s = Math.min(15, Math.max(0, args[1])); + const { data, error } = await _sb.from('students').update({ behavior_score: s }).eq('id', args[0]).select(); + if (error) throw error; return data; + } + if (fnName === 'getAllAssignments') { + const { data, error } = await _sb.from('assignments').select('*').order('created_at', { ascending: false }); + if (error) throw error; return data || []; + } + if (fnName === 'getAssignmentById') { + const { data } = await _sb.from('assignments').select('*').eq('id', args[0]).maybeSingle(); return data; + } + if (fnName === 'createAssignment') { + const d = args[0]; + const payload = { name: d.name, subject: d.subject, classroom: d.classroom, + category: d.category || 'ก่อนกลางภาค', passing_score: d.passing_score || 0, + max_score: d.max_score, type: d.type || 'เดี่ยว' }; + const { data: newA, error } = await _sb.from('assignments').insert(payload).select().single(); + if (error) throw error; + const { data: stList } = await _sb.from('students').select('id').eq('classroom', d.classroom); + if (stList?.length) { + const grPay = stList.map(s => ({ student_id: s.id, assignment_id: newA.id, score: null, max_score: d.max_score, status: 'not_sent' })); + await _sb.from('grades').upsert(grPay, { onConflict: 'student_id,assignment_id' }); + } + return newA; + } + if (fnName === 'deleteAssignment') { + const { error } = await _sb.from('assignments').delete().eq('id', args[0]); + if (error) throw error; return true; + } + if (fnName === 'getGradesByStudent') { + const { data, error } = await _sb.from('grades').select('*, assignments(*)').eq('student_id', args[0]); + if (error) throw error; return data || []; + } + if (fnName === 'getGradesByAssignment') { + const { data, error } = await _sb.from('grades').select('*').eq('assignment_id', args[0]); + if (error) throw error; return data || []; + } + if (fnName === 'saveGrades') { + const now = new Date().toISOString(); + const payload = args[0].map(r => ({ + student_id: r.student_id, assignment_id: r.assignment_id, + score: r.score, max_score: r.max_score, status: r.status, updated_at: now + })); + const { data, error } = await _sb.from('grades').upsert(payload, { onConflict: 'student_id,assignment_id' }).select(); + if (error) throw error; return data; + } + if (fnName === 'getGradesByRoom') { + const [classroom, subject] = args; + const { data: asgns } = await _sb.from('assignments').select('*').eq('classroom', classroom).eq('subject', subject).order('created_at'); + const { data: sts } = await _sb.from('students').select('*').eq('classroom', classroom).order('seat_no'); + if (!asgns?.length || !sts?.length) return { students: sts || [], assignments: asgns || [], grades: [] }; + const aIds = asgns.map(a => a.id), sIds = sts.map(s => s.id); + const { data: grs } = await _sb.from('grades').select('*').in('student_id', sIds).in('assignment_id', aIds); + return { students: sts, assignments: asgns, grades: grs || [] }; + } + if (fnName === 'getAttendanceByStudent') { + const { data, error } = await _sb.from('attendance').select('*').eq('student_id', args[0]).order('attendance_date', { ascending: false }); + if (error) throw error; return data || []; + } + if (fnName === 'getAttendanceByDate') { + const [classroom, date] = args; + const { data: sts } = await _sb.from('students').select('id').eq('classroom', classroom); + if (!sts?.length) return []; + const sIds = sts.map(s => s.id); + const { data } = await _sb.from('attendance').select('*').in('student_id', sIds).eq('attendance_date', date); + return data || []; + } + if (fnName === 'saveAttendance') { + const now = new Date().toISOString(); + const payload = args[0].map(r => ({ + student_id: r.student_id, attendance_date: r.attendance_date, + subject: r.subject, status: r.status, remark: r.remark || '', hours: r.hours || 1, updated_at: now + })); + const { data, error } = await _sb.from('attendance').upsert(payload, { onConflict: 'student_id,attendance_date,subject' }).select(); + if (error) throw error; return data; + } + if (fnName === 'getAttendanceForRoom') { + const { data: sts } = await _sb.from('students').select('id').eq('classroom', args[0]); + if (!sts?.length) return []; + const sIds = sts.map(s => s.id); + const { data } = await _sb.from('attendance').select('*').in('student_id', sIds).order('attendance_date', { ascending: true }); + return data || []; + } + if (fnName === 'markStudentSubmitted') { + const [studentId, assignmentId] = args; + const student = await gasCall('getStudentById', studentId); + const assignment = await gasCall('getAssignmentById', assignmentId); + if (!student || !assignment) throw new Error('ไม่พบข้อมูล'); + const saved = await gasCall('saveGrades', [{ student_id: studentId, assignment_id: assignmentId, score: assignment.max_score, max_score: assignment.max_score, status: 'checked' }]); + return { success: true, student, assignment, saved }; + } + if (fnName === 'markStudentAttendance') { + // FIX #5: รับ subject และ hours ด้วย + const [studentId, date, status, remark, subject, hours] = args; + const student = await gasCall('getStudentById', studentId); + if (!student) throw new Error('ไม่พบนักเรียน'); + const saved = await gasCall('saveAttendance', [{ student_id: studentId, attendance_date: date, subject: subject || '', status, remark: remark || '', hours: hours || 1 }]); + return { success: true, student, attendance_date: date, status, saved }; + } + throw new Error('Unknown function: ' + fnName); +}; + +// ─── ADMIN INIT ──────────────────────────────────────────── +const TABS = ['grading','attendance','behavior','students','asgn','export']; + +function adminTab(name) { + stopQrScanner(); stopAttendanceScanner(); + TABS.forEach(t => { + const tab = $('tab-' + t); if (tab) tab.style.display = t === name ? 'block' : 'none'; + const btn = $('t-' + t); if (btn) btn.className = 'tab-btn' + (t === name ? ' active' : ''); + }); + if (name === 'behavior') renderBehaviorList(); +} + +async function initAdmin() { + // FIX #3: ป้องกัน double-init + if (adminInitialized) return; + adminInitialized = true; + + $('nav-logout').style.display = 'flex'; + try { + const [s, a] = await Promise.all([gasCall('getStudents'), gasCall('getAllAssignments')]); + students = s; assignments = a; + $('cfg-dot').className = 'cfg-dot ok'; + } catch (e) { + showToast('โหลดข้อมูลล้มเหลว: ' + e, 'error'); + adminInitialized = false; // allow retry + return; + } + populateDropdowns(); + if (!$('att-date').value) $('att-date').value = new Date().toISOString().slice(0, 10); + renderStudentTable(); renderAsgnTable(); adminTab('grading'); +} + +// ─── DROPDOWNS ───────────────────────────────────────────── +function populateDropdowns() { + const rooms = [...new Set(students.map(s => s.classroom))].sort(); + const subjs = [...new Set(assignments.map(a => a.subject))].sort(); + ['g-room','att-room','f-room','exp-room','exp-att-room'].forEach(id => { + const el = $(id); if (!el) return; + const cur = el.value; + const ph = id === 'f-room' ? '' : ''; + el.innerHTML = ph + rooms.map(r => ``).join(''); + el.value = cur; + }); + ['g-subj','att-subj','exp-subj','exp-att-subj'].forEach(id => { + const el = $(id); if (!el) return; + const cur = el.value; + el.innerHTML = '' + subjs.map(s => ``).join(''); + el.value = cur; + }); + syncAssignSelect(); +} + +function syncAssignSelect() { + const subj = $('g-subj').value, room = $('g-room').value, el = $('g-asgn'); + const cur = el.value; + el.innerHTML = ''; + assignments.filter(a => (!subj || a.subject === subj) && (!room || a.classroom === room)) + .forEach(a => { el.innerHTML += ``; }); + el.value = cur; +} + +// ─── STATUS PAGE ─────────────────────────────────────────── +async function searchStatus() { + const sid = $('st-id-inp').value.trim(); + if (!sid) { showToast('กรุณาใส่รหัสนักเรียน', 'error'); return; } + + $('st-results').style.display = 'none'; + $('st-att-card').style.display = 'none'; + $('st-empty').style.display = 'none'; + + try { + const [grades, attendance, stInfo] = await Promise.all([ + gasCall('getGradesByStudent', sid), + gasCall('getAttendanceByStudent', sid), + gasCall('getStudentById', sid) + ]); + + if (!stInfo) { $('st-empty').style.display = 'block'; return; } + + const fullName = stInfo.first_name + ' ' + stInfo.last_name; + $('st-name').textContent = fullName; + $('st-meta').textContent = 'ชั้น ' + stInfo.classroom + ' เลขที่ ' + stInfo.seat_no + ' | รหัส: ' + sid; + $('st-name-display').textContent = fullName; + $('st-meta-display').textContent = 'ชั้น ' + stInfo.classroom + ' เลขที่ ' + stInfo.seat_no; + + const catStats = { + 'ก่อนกลางภาค': { current: 0, max: 0, weight: 20 }, + 'กลางภาค': { current: 0, max: 0, weight: 20 }, + 'หลังกลางภาค': { current: 0, max: 0, weight: 20 }, + 'ปลายภาค': { current: 0, max: 0, weight: 30 } + }; + + // deduplicate by assignment_id (keep highest score) + const uniqueMap = {}; + grades.forEach(g => { + if (!g.assignments) return; + const aid = g.assignment_id; + const sc = parseFloat(g.score || 0); + if (!uniqueMap[aid] || sc > parseFloat(uniqueMap[aid].score || 0)) uniqueMap[aid] = g; + }); + + const sorted = Object.values(uniqueMap).sort((a, b) => { + const sa = (a.assignments.subject||'').toLowerCase(), sb = (b.assignments.subject||'').toLowerCase(); + const na = (a.assignments.name||'').toLowerCase(), nb = (b.assignments.name||'').toLowerCase(); + return sa.localeCompare(sb) || na.localeCompare(nb); + }); + + let countOk = 0, countWait = 0, countNo = 0; + const tbody = $('st-tbody'); + tbody.innerHTML = ''; + let lastSubject = null; + + if (!sorted.length) { + tbody.innerHTML = 'ยังไม่มีรายการงานในขณะนี้'; + } + + sorted.forEach(g => { + const a = g.assignments || {}; + const cat = a.category || 'ก่อนกลางภาค'; + const type = a.type || 'ทั่วไป'; + const sc = parseFloat(g.score || 0); + const max = parseFloat(a.max_score || 10); + const pass = parseFloat(a.passing_score || 0); + + if (catStats[cat]) { + catStats[cat].max += max; + if (g.status === 'checked') catStats[cat].current += sc; + } + + const subjectName = a.subject || 'วิชาทั่วไป'; + if (subjectName !== lastSubject) { + // FIX: ใช้ textContent แทน innerHTML เพื่อป้องกัน XSS + const tr = document.createElement('tr'); + tr.className = 'tr-subject-group'; + const td = document.createElement('td'); + td.colSpan = 5; + td.textContent = '📚 ' + subjectName; + tr.appendChild(td); + tbody.appendChild(tr); + lastSubject = subjectName; + } + + let badge = '', rowClass = ''; + if (g.status === 'checked') { + badge = (sc < pass && pass > 0) + ? '❌ ไม่ผ่าน' + : '✅ ส่งแล้ว'; + rowClass = (sc < pass && pass > 0) ? 'row-ขาด' : 'row-มา'; + countOk++; + } else if (g.status === 'waiting') { + badge = '⏳ รอตรวจ'; rowClass = 'row-สาย'; countWait++; + } else { + badge = '🚫 ยังไม่ส่ง'; rowClass = 'row-not-sent'; countNo++; + } + + const typeClass = type.includes('สอบ') ? 'badge-amber' : 'badge-blue'; + const scoreHtml = g.status === 'checked' + ? `${g.score} / ${max}` + : ''; + + // FIX: สร้าง row ด้วย DOM แทน innerHTML เพื่อป้องกัน XSS จาก a.name / a.description + const tr = document.createElement('tr'); + tr.className = rowClass; + + const descHtml = a.description + ? `
📝 ${escapeHtml(a.description)}
` + : ''; + + tr.innerHTML = ` + ${escapeHtml(cat)} + + ${escapeHtml(a.name || '—')} + ${descHtml} + + ${escapeHtml(type)} + ${badge} + ${scoreHtml}`; + tbody.appendChild(tr); + }); + + // weighted score + let weightedTotal = 0; + const chartPoints = []; + ['ก่อนกลางภาค','กลางภาค','หลังกลางภาค','ปลายภาค'].forEach(lbl => { + const s = catStats[lbl]; + const pt = s.max > 0 ? (s.current / s.max) * s.weight : 0; + weightedTotal += pt; chartPoints.push(pt.toFixed(2)); + }); + const behaviorScore = (stInfo.behavior_score !== null && stInfo.behavior_score !== undefined) + ? parseFloat(stInfo.behavior_score) : 10; + chartPoints.push(behaviorScore.toFixed(2)); + const grandTotal = weightedTotal + behaviorScore; + + $('final-weighted-score').textContent = grandTotal.toFixed(1); + $('final-weighted-score').style.color = grandTotal >= 50 ? '#fff' : 'var(--red)'; + $('cnt-all').textContent = sorted.length; + $('cnt-ok').textContent = countOk; + $('cnt-wait').textContent = countWait; + $('cnt-no').textContent = countNo; + + $('behavior-score-text').textContent = behaviorScore + '/15'; + const bhPct = (behaviorScore / 15) * 100; + $('behavior-progress-fill').style.width = bhPct + '%'; + $('behavior-progress-fill').style.background = behaviorScore > 10 ? 'linear-gradient(90deg,var(--purple),#ec4899)' : 'var(--purple)'; + + const subPct = sorted.length > 0 ? ((countOk + countWait) / sorted.length * 100) : 0; + $('global-progress-fill').style.width = subPct + '%'; + $('global-progress-text').textContent = subPct.toFixed(0) + '%'; + + updatePieChart(chartPoints); + + // attendance grid + const attCard = $('st-att-card'); + const attContent = $('st-att-content'); + attContent.innerHTML = ''; + if (attendance?.length) { + const grouped = attendance.reduce((acc, cur) => { + const subj = cur.subject || 'ไม่ระบุวิชา'; + if (!acc[subj]) acc[subj] = []; + acc[subj].push(cur); + return acc; + }, {}); + for (const subj in grouped) renderAttendanceGrid(subj, grouped[subj], attContent); + attCard.style.display = 'block'; + } + + $('st-results').style.display = 'block'; + showToast('ดึงข้อมูลสำเร็จ', 'success'); + + } catch (e) { + console.error(e); + showToast('เกิดข้อผิดพลาด: ' + e.message, 'error'); + } +} + +// FIX #7: helper escape HTML +function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function updatePieChart(dataPoints) { + const ctx = $('scoreChart').getContext('2d'); + if (scorePieChart) scorePieChart.destroy(); + Chart.register(ChartDataLabels); + scorePieChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: ['ก่อนกลางภาค','กลางภาค','หลังกลางภาค','ปลายภาค','จิตพิสัย'], + datasets: [{ + data: dataPoints, + backgroundColor: ['#0ea5e9','#10b981','#f59e0b','#ef4444','#a855f7'], + borderWidth: 2, borderColor: '#050e30', hoverOffset: 15 + }] + }, + options: { + cutout: '50%', layout: { padding: 10 }, + plugins: { + legend: { display: false }, + tooltip: { enabled: true }, + datalabels: { + color: '#ffffff', + font: { family: 'Kanit', weight: 'bold', size: 14 }, + anchor: 'center', align: 'center', + formatter: v => parseFloat(v) > 0 ? parseFloat(v).toFixed(1) : '' + } + }, + responsive: true, maintainAspectRatio: false + } + }); +} + +function renderAttendanceGrid(subjectName, attendanceData, container) { + const wrapper = document.createElement('div'); + wrapper.className = 'mb-4'; + + const uniqueDates = [...new Set(attendanceData.map(a => a.attendance_date))].sort(); + const attMap = {}; + let attendedHours = 0, totalHours = 0; + + attendanceData.forEach(a => { + attMap[a.attendance_date] = a; + const h = parseFloat(a.hours) || 1; + totalHours += h; + if (a.status === 'มา') attendedHours += h; + else if (a.status === 'ลา') attendedHours += h * 0.25; + else if (a.status === 'สาย') attendedHours += h * 0.5; + }); + const pct = totalHours > 0 ? (attendedHours / totalHours) * 100 : 0; + const pctColor = pct >= 80 ? '#22d3ee' : '#ef4444'; + + let headHtml = 'รายการ \\ วันที่'; + uniqueDates.forEach(date => { + const d = new Date(date); + const h = attMap[date].hours || 1; + const disp = d.getDate() + '/' + (d.getMonth()+1) + '/' + (d.getFullYear()+543).toString().slice(-2); + headHtml += `
${disp}
(${h} ชม.)
`; + }); + headHtml += 'มา/รวมร้อยละ'; + + let bodyHtml = 'สถานะ'; + uniqueDates.forEach(date => { + const rec = attMap[date]; + let symbol = '-', cellBg = 'rgba(255,255,255,0.03)'; + if (rec) { + if (rec.status === 'มา') { symbol = '✅'; cellBg = 'rgba(16,185,129,0.2)'; } + else if (rec.status === 'ขาด') { symbol = '❌'; cellBg = 'rgba(239,68,68,0.2)'; } + else if (rec.status === 'ลา') { symbol = '📝'; cellBg = 'rgba(14,165,233,0.2)'; } + else if (rec.status === 'สาย') { symbol = '⏰'; cellBg = 'rgba(245,158,11,0.2)'; } + } + bodyHtml += `${symbol}`; + }); + bodyHtml += `${attendedHours}/${totalHours} + ${pct.toFixed(0)}%`; + + wrapper.innerHTML = ` +
+ 📚 วิชา: ${escapeHtml(subjectName)} +
+
+ + ${headHtml} + ${bodyHtml} +
+
`; + container.appendChild(wrapper); +} + +// ─── GRADING ─────────────────────────────────────────────── +async function loadGrading() { + stopQrScanner(); $('scan-sw').checked = false; $('scan-area').style.display = 'none'; + const aid = $('g-asgn').value, room = $('g-room').value; + if (!aid || !room) { showToast('กรุณาเลือกห้องและงาน', 'error'); return; } + const asgn = assignments.find(a => a.id === aid); + $('grading-title').textContent = '📋 ' + (asgn?.name || 'รายชื่อนักเรียน') + ' — ห้อง ' + room; + $('grading-wrap').style.display = 'block'; + $('grading-tbody').innerHTML = '
กำลังโหลด...
'; + try { + const classStudents = students.filter(s => s.classroom === room).sort((a,b) => a.seat_no - b.seat_no); + const gradesRaw = await gasCall('getGradesByAssignment', aid); + const gMap = {}; + (gradesRaw || []).forEach(g => gMap[g.student_id] = g); + gradingRows = classStudents.map(s => { + const g = gMap[s.id] || {}; + return { student: s, gradeId: g.id || null, status: g.status || 'not_sent', + score: g.score !== undefined && g.score !== null ? g.score : null, + maxScore: g.max_score || asgn?.max_score || 10 }; + }); + renderGradingTable(); + } catch (e) { + showToast('โหลดล้มเหลว: ' + e, 'error'); + $('grading-tbody').innerHTML = ''; + } +} + +function togLabel(s) { + if (s === 'checked') return '✅ ตรวจแล้ว'; + if (s === 'waiting') return '⏳ รอตรวจ'; + return '❌ ยังไม่ส่ง'; +} + +function renderGradingTable() { + const tb = $('grading-tbody'); tb.innerHTML = ''; + gradingRows.forEach((row, i) => { + const s = row.student; + const cls = row.status === 'checked' ? 'checked' : row.status === 'waiting' ? 'waiting' : 'not-sent'; + // FIX #7: ใช้ escapeHtml กับข้อมูลนักเรียน + tb.innerHTML += ` + ${s.seat_no || '—'} + ${escapeHtml(s.id)} + ${escapeHtml(s.first_name)} ${escapeHtml(s.last_name)} + + + ${row.maxScore} + `; + }); +} + +function toggleRow(i) { + gradingRows[i].status = gradingRows[i].status === 'checked' ? 'not-sent' : 'checked'; + const btn = $('tog-' + i); + btn.className = 'tog ' + (gradingRows[i].status === 'checked' ? 'checked' : 'not-sent'); + btn.textContent = togLabel(gradingRows[i].status); +} + +function markAllStatus(s) { + gradingRows.forEach((_, i) => { + gradingRows[i].status = s; + const btn = $('tog-' + i); + if (btn) { btn.className = 'tog ' + (s === 'checked' ? 'checked' : 'not-sent'); btn.textContent = togLabel(s); } + }); +} + +async function saveGradesNow() { + const aid = $('g-asgn').value; + if (!aid) { showToast('กรุณาเลือกงานก่อน', 'error'); return; } + const rows = gradingRows.map((row, i) => { + const sv = $('sc-' + i).value; + // FIX #4: score=0 ต้องไม่ถูก treat เป็น null, ช่องว่าง = null เสมอ + const score = sv !== '' ? parseFloat(sv) : null; + const status = score !== null ? 'checked' : row.status; + return { student_id: row.student.id, assignment_id: aid, score, max_score: row.maxScore, status }; + }); + try { + await gasCall('saveGrades', rows); + showToast('บันทึกคะแนนสำเร็จ! 🎉', 'success'); + await loadGrading(); + } catch (e) { showToast('บันทึกล้มเหลว: ' + e, 'error'); } +} + +// ─── SCAN ────────────────────────────────────────────────── +function setScanMode(on) { + $('scan-area').style.display = on ? 'block' : 'none'; + if (on) { startQrScanner(); $('scan-inp').focus(); } else stopQrScanner(); +} + +async function startQrScanner() { + if (scanning) return; + const aid = $('g-asgn').value; + if (!aid) { showToast('กรุณาเลือกงานก่อน', 'warn'); $('scan-sw').checked = false; $('scan-area').style.display = 'none'; return; } + try { + html5QrCode = new Html5Qrcode('reader'); scanning = true; + await html5QrCode.start({ facingMode:'environment' }, { fps:10, qrbox:{width:220,height:220} }, + async decodedText => { + const now = Date.now(); + if (decodedText === lastScannedText && now - lastScannedAt < 2500) return; + lastScannedText = decodedText; lastScannedAt = now; + await handleScan(decodedText); + }, () => {} + ); + showToast('เปิดกล้องสแกน QR แล้ว', 'success'); + } catch (e) { scanning = false; showToast('เปิดกล้องไม่ได้: ' + e, 'error'); } +} + +async function stopQrScanner() { + try { if (html5QrCode && scanning) { await html5QrCode.stop(); await html5QrCode.clear(); } } catch (e) {} + scanning = false; +} + +function extractStudentId(raw) { + raw = (raw || '').trim(); if (!raw) return ''; + if (raw.startsWith('http://') || raw.startsWith('https://')) { + try { return new URL(raw).searchParams.get('student_id') || raw; } catch (e) { return raw; } + } + return raw; +} + +async function handleScan(val) { + const studentId = extractStudentId(val); if (!studentId) return; + const aid = $('g-asgn').value; if (!aid) { showToast('กรุณาเลือกงานก่อนสแกน', 'error'); return; } + const idx = gradingRows.findIndex(r => r.student.id === studentId); + if (idx === -1) { showToast('ไม่พบรหัส: ' + studentId, 'error'); return; } + try { + const result = await gasCall('markStudentSubmitted', studentId, aid); + gradingRows[idx].status = 'checked'; gradingRows[idx].score = result.assignment.max_score; + const btn = $('tog-' + idx); + if (btn) { btn.className = 'tog checked'; btn.textContent = togLabel('checked'); } + const sc = $('sc-' + idx); if (sc) sc.value = result.assignment.max_score; + const row = $('gr-' + idx); + if (row) { row.scrollIntoView({behavior:'smooth',block:'center'}); row.style.background='rgba(16,185,129,.1)'; setTimeout(()=>row.style.background='',1200); } + showToast(result.student.first_name + ' ' + result.student.last_name, 'success'); + } catch (e) { showToast('บันทึกไม่สำเร็จ: ' + e, 'error'); } +} + +// ─── ATTENDANCE (ADMIN) ──────────────────────────────────── +async function loadAttendance() { + stopAttendanceScanner(); $('att-scan-sw').checked = false; $('att-scan-area').style.display = 'none'; + const room = $('att-room').value, date = $('att-date').value; + if (!room || !date) { showToast('กรุณาเลือกห้องและวันที่', 'error'); return; } + $('attendance-title').textContent = '📋 เช็กชื่อ ห้อง ' + room + ' วันที่ ' + date; + $('attendance-wrap').style.display = 'block'; + $('attendance-tbody').innerHTML = '
กำลังโหลด...
'; + try { + const classStudents = students.filter(s => s.classroom === room).sort((a,b) => a.seat_no - b.seat_no); + const raw = await gasCall('getAttendanceByDate', room, date); + const aMap = {}; + (raw || []).forEach(a => aMap[a.student_id] = a); + attendanceRows = classStudents.map(s => { + const a = aMap[s.id] || {}; + return { student: s, status: a.status || 'ขาด', remark: a.remark || '' }; + }); + renderAttendanceTable(); + } catch (e) { + showToast('โหลดการเข้าเรียนล้มเหลว: ' + e, 'error'); + $('attendance-tbody').innerHTML = ''; + } +} + +function renderAttendanceTable() { + const tb = $('attendance-tbody'); if (!tb) return; + tb.innerHTML = ''; + attendanceRows.forEach((row, i) => { + const sel = 'att-select select-' + row.status; + // FIX #7: escape ชื่อนักเรียน + tb.innerHTML += ` + ${row.student.seat_no || '—'} + ${escapeHtml(row.student.id)} + ${escapeHtml(row.student.first_name)} ${escapeHtml(row.student.last_name)} + + + + + `; + }); +} + +function updateAttendanceRowVisual(index, value) { + attendanceRows[index].status = value; + const tr = $('att-row-' + index), sel = $('att-status-' + index); + if (tr) tr.className = 'row-' + value; + if (sel) sel.className = 'att-select select-' + value; +} + +function markAllAttendance(status) { + attendanceRows.forEach((row, i) => { + row.status = status; + const el = $('att-status-' + i); if (el) el.value = status; + updateAttendanceRowVisual(i, status); + }); +} + +async function saveAttendanceNow() { + const date = $('att-date').value; + const subj = $('att-subj').value; + const room = $('att-room').value; + const hours = parseFloat($('att-hours').value) || 1; + if (!date || !subj || !room) { showToast('กรุณาเลือกวิชา ห้อง และวันที่ให้ครบ', 'error'); return; } + const rows = attendanceRows.map((row, i) => ({ + student_id: row.student.id, + attendance_date: date, subject: subj, + status: $('att-status-' + i) ? $('att-status-' + i).value : row.status, + remark: $('att-remark-' + i) ? $('att-remark-' + i).value : '', + hours + })); + try { + await gasCall('saveAttendance', rows); + showToast('บันทึกการเข้าเรียนวิชา ' + subj + ' สำเร็จ', 'success'); + } catch (e) { showToast('บันทึกไม่สำเร็จ: ' + (e.message || e), 'error'); } +} + +function setAttendanceScanMode(on) { + $('att-scan-area').style.display = on ? 'block' : 'none'; + if (on) { startAttendanceScanner(); $('att-scan-inp').focus(); } else stopAttendanceScanner(); +} + +async function startAttendanceScanner() { + if (attendanceScanning) return; + const room = $('att-room').value, date = $('att-date').value; + if (!room || !date) { showToast('กรุณาเลือกห้องและวันที่ก่อนสแกน','warn'); $('att-scan-sw').checked=false; $('att-scan-area').style.display='none'; return; } + try { + attendanceQr = new Html5Qrcode('att-reader'); attendanceScanning = true; + await attendanceQr.start({ facingMode:'environment' }, { fps:10, qrbox:{width:220,height:220} }, + async decodedText => { + const now = Date.now(); + if (decodedText === lastAttendanceScan && now - lastAttendanceScanAt < 2500) return; + lastAttendanceScan = decodedText; lastAttendanceScanAt = now; + await handleAttendanceScan(decodedText); + }, () => {} + ); + showToast('เปิดกล้องเช็กชื่อแล้ว', 'success'); + } catch (e) { attendanceScanning=false; showToast('เปิดกล้องไม่ได้: '+e,'error'); } +} + +async function stopAttendanceScanner() { + try { if (attendanceQr && attendanceScanning) { await attendanceQr.stop(); await attendanceQr.clear(); } } catch (e) {} + attendanceScanning = false; +} + +async function handleAttendanceScan(val) { + const studentId = extractStudentId(val); if (!studentId) return; + const date = $('att-date').value; + const status = $('att-default-status').value; + // FIX #5: ส่ง subject และ hours ไปด้วย + const subj = $('att-subj').value; + const hours = parseFloat($('att-hours').value) || 1; + const idx = attendanceRows.findIndex(r => r.student.id === studentId); + if (idx === -1) { showToast('ไม่พบรหัส: ' + studentId, 'error'); return; } + try { + const result = await gasCall('markStudentAttendance', studentId, date, status, '', subj, hours); + attendanceRows[idx].status = status; + const statusEl = $('att-status-' + idx); if (statusEl) statusEl.value = status; + updateAttendanceRowVisual(idx, status); + const row = $('att-row-' + idx); if (row) row.scrollIntoView({behavior:'smooth',block:'center'}); + showToast(result.student.first_name + ' ' + result.student.last_name + ' = ' + status, 'success'); + } catch (e) { showToast('เช็กชื่อไม่สำเร็จ: ' + e, 'error'); } +} + +// ─── STUDENTS TAB ────────────────────────────────────────── +function renderStudentTable() { filterStudents(); } + +function filterStudents() { + const room = $('f-room').value, q = $('f-q').value.toLowerCase(); + const list = students.filter(s => + (!room || s.classroom === room) && + (!q || s.id.toLowerCase().includes(q) || s.first_name.toLowerCase().includes(q) || s.last_name.toLowerCase().includes(q)) + ); + const tb = $('st-tbody-admin'); + if (!list.length) { tb.innerHTML = '
🔍
ไม่มีข้อมูล
'; return; } + + // FIX #7: ไม่ใช้ JSON.stringify ใน onclick — ใช้ data-id แทน + tb.innerHTML = list.map(s => ` + ${escapeHtml(s.id)} + ${escapeHtml(s.first_name)} + ${escapeHtml(s.last_name)} + ${escapeHtml(s.classroom)} + ${s.seat_no} + +
+ + +
+ + + `).join(''); + + // event delegation แทน inline onclick + tb.querySelectorAll('button[data-action]').forEach(btn => { + btn.addEventListener('click', () => { + const sid = btn.dataset.id; + const st = students.find(s => s.id === sid); + if (!st) return; + if (btn.dataset.action === 'qr') showQR(st.id, st.first_name + ' ' + st.last_name, st.classroom); + if (btn.dataset.action === 'edit') editStudent(st); + if (btn.dataset.action === 'del') delStudent(st.id); + }); + }); +} + +function prepareAddStudent() { + const modalTitle = document.querySelector('#m-add-st .modal-hd'); + if (modalTitle) modalTitle.innerHTML = `➕ เพิ่มนักเรียน `; + $('ns-id').value = ''; + $('ns-id').readOnly = false; + $('ns-id').style.opacity = "1"; + $('ns-fn').value = ''; + $('ns-ln').value = ''; + $('ns-cls').value = ''; + $('ns-seat').value = ''; + $('ns-email').value = ''; + openModal('m-add-st'); +} + +async function ensureGradeRowsForStudent(studentId, classroom) { + const roomAssignments = assignments.filter(a => a.classroom === classroom); + if (!roomAssignments.length) return; + const { data: existingGrades } = await _sb + .from('grades').select('assignment_id').eq('student_id', studentId) + .in('assignment_id', roomAssignments.map(a => a.id)); + const existingIds = new Set((existingGrades || []).map(g => g.assignment_id)); + const newRows = roomAssignments + .filter(a => !existingIds.has(a.id)) + .map(a => ({ student_id: studentId, assignment_id: a.id, score: null, max_score: a.max_score, status: 'not_sent' })); + if (newRows.length > 0) { + const { error } = await _sb.from('grades').upsert(newRows, { onConflict: 'student_id,assignment_id', ignoreDuplicates: true }); + if (error) console.error('ensureGradeRowsForStudent error:', error); + } +} + +async function saveStudent() { + const d = { + id: $('ns-id').value.trim(), + first_name: $('ns-fn').value.trim(), + last_name: $('ns-ln').value.trim(), + classroom: $('ns-cls').value.trim(), + seat_no: parseInt($('ns-seat').value) || 0, + email: $('ns-email').value.trim() + }; + if (!d.id || !d.first_name || !d.last_name || !d.classroom) { + showToast('กรุณากรอกข้อมูลให้ครบ', 'error'); return; + } + try { + await gasCall('upsertStudent', d); + await ensureGradeRowsForStudent(d.id, d.classroom); + closeModal('m-add-st'); + showToast('บันทึกสำเร็จ', 'success'); + students = await gasCall('getStudents'); + assignments = await gasCall('getAllAssignments'); + populateDropdowns(); renderStudentTable(); + } catch (e) { showToast('ผิดพลาด: ' + e, 'error'); } +} + +function editStudent(s) { + const modalTitle = document.querySelector('#m-add-st .modal-hd'); + if (modalTitle) modalTitle.innerHTML = `✏️ แก้ไขข้อมูลนักเรียน `; + $('ns-id').value = s.id; + $('ns-id').readOnly = true; + $('ns-id').style.opacity = "0.6"; + $('ns-fn').value = s.first_name; + $('ns-ln').value = s.last_name; + $('ns-cls').value = s.classroom; + $('ns-seat').value = s.seat_no; + $('ns-email').value = s.email || ''; + openModal('m-add-st'); +} + +async function delStudent(id) { + if (!confirm('ลบนักเรียนรหัส ' + id + '?')) return; + try { + await gasCall('deleteStudent', id); + showToast('ลบแล้ว', 'success'); + students = await gasCall('getStudents'); + populateDropdowns(); renderStudentTable(); + } catch (e) { showToast('ผิดพลาด: ' + e, 'error'); } +} + +// ─── TEMPLATE DOWNLOAD ───────────────────────────────────── +// FIX #1: เพิ่มฟังก์ชัน dlTemplate ที่หายไป +function dlTemplate() { + const header = [['รหัสนักเรียน','ชื่อ','สกุล','ชั้น','เลขที่','อีเมล']]; + const ws = XLSX.utils.aoa_to_sheet(header); + ws['!cols'] = [{ wch:16 },{ wch:16 },{ wch:16 },{ wch:10 },{ wch:8 },{ wch:28 }]; + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'นักเรียน'); + XLSX.writeFile(wb, 'template_นักเรียน.xlsx'); +} + +// ─── IMPORT ──────────────────────────────────────────────── +async function importFile(input) { + const file = input.files[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = async e => { + try { + const wb = XLSX.read(e.target.result, { type: 'binary' }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws); + const list = rows.map(r => ({ + id: String(r['รหัสนักเรียน'] || r['id'] || ''), + first_name: String(r['ชื่อ'] || r['first_name'] || ''), + last_name: String(r['สกุล'] || r['last_name'] || ''), + classroom: String(r['ชั้น'] || r['classroom'] || ''), + seat_no: parseInt(r['เลขที่'] || r['seat_no'] || 0), + email: String(r['อีเมล'] || r['email'] || '') + })).filter(s => s.id); + await gasCall('upsertStudents', list); + showToast('นำเข้า ' + list.length + ' คนสำเร็จ', 'success'); + students = await gasCall('getStudents'); + assignments = await gasCall('getAllAssignments'); + showToast('กำลังตั้งค่างานค้าง...', 'info'); + for (const s of list) { + await ensureGradeRowsForStudent(s.id, s.classroom); + } + populateDropdowns(); renderStudentTable(); + showToast(`✅ ตั้งค่างานค้างสำเร็จ (${list.length} คน)`, 'success'); + } catch (e) { showToast('ผิดพลาด: ' + e, 'error'); } + }; + reader.readAsBinaryString(file); input.value = ''; +} + +// ─── QR CODE ─────────────────────────────────────────────── +function showQR(id, name, cls) { + $('qr-wrap').innerHTML = ''; + new QRCode($('qr-wrap'), { text: id, width: 192, height: 192, colorDark: '#0f172a', colorLight: '#fff', correctLevel: QRCode.CorrectLevel.H }); + $('qr-info').innerHTML = `${escapeHtml(name)}
${escapeHtml(cls)} | รหัส: ${escapeHtml(id)}`; + openModal('m-qr'); +} + +function printQR() { + const qr = $('qr-wrap').innerHTML, info = $('qr-info').innerHTML; + const w = window.open('', '_blank'); + // FIX #8: ตรวจ popup blocker + if (!w) { showToast('กรุณาอนุญาต Popup ในเบราว์เซอร์', 'warn'); return; } + w.document.write(`QR
${qr}
${info}