Files
survey/js/editor.js
T
2026-09-12 14:34:09 +07:00

536 lines
29 KiB
JavaScript

/**
* TPU Survey - Standalone Editor Logic
* รองรับ Section -> Heading -> Question
*/
let currentSurvey = null;
let isDirty = false;
async function initEditor() {
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('id');
if (!id) {
alert('ไม่ระบุ ID แบบสอบถาม');
window.close();
return;
}
try {
const data = await API.get(`surveys.php?action=get&id=${id}`);
currentSurvey = data.survey;
renderEditor();
} catch (err) {
Toast.error(err.message);
document.getElementById('editorContent').innerHTML = `
<div class="empty-state">
<div class="empty-icon">❌</div>
<h3>เกิดข้อผิดพลาด</h3>
<p>${err.message}</p>
<button class="btn btn-primary" onclick="window.close()">ปิดหน้านี้</button>
</div>
`;
}
}
function renderEditor() {
if (!currentSurvey) return;
document.title = `แก้ไข: ${currentSurvey.title} - TPU Survey`;
document.getElementById('surveyTitleDisplay').textContent = currentSurvey.title;
let html = `
<div class="card mb-32" style="border:none; box-shadow:var(--shadow-md); background:var(--bg-card); border-radius:var(--radius-xl);">
<div class="card-body" style="padding:40px;">
<div class="form-group mb-24">
<label class="form-label label-lg" style="font-size:1.2rem; font-weight:800;">ชื่อแบบสอบถาม</label>
<input type="text" id="edit-survey-title" class="form-input" style="font-size:1.6rem; font-weight:800; height:70px;"
value="${escapeHtml(currentSurvey.title)}" oninput="markDirty()">
</div>
<div class="form-group mb-24">
<label class="form-label">รายละเอียด / คำชี้แจง</label>
<textarea id="edit-survey-desc" class="form-input" rows="3" oninput="markDirty()">${escapeHtml(currentSurvey.description || '')}</textarea>
</div>
<div class="form-group mt-16" style="display:flex; align-items:center; gap:10px;">
<input type="checkbox" id="edit-survey-cert" style="width:22px; height:22px; cursor:pointer;"
${currentSurvey.has_certificate == 1 ? 'checked' : ''} onchange="toggleCertPanel(); markDirty();">
<label for="edit-survey-cert" style="font-weight:700; cursor:pointer; user-select:none; font-size:0.95rem; color:var(--primary-dark);">🎓 เปิดใช้งานและจัดส่งเกียรติบัตรทางอีเมลอัตโนมัติสำหรับแบบสอบถามนี้</label>
</div>
<!-- พาเนลตั้งค่าเกียรติบัตร -->
<div id="cert-config-panel" style="margin-top:20px; padding:24px; border:1px dashed var(--primary-color, #2563eb); border-radius:12px; background:rgba(37,99,235,0.02); display:${currentSurvey.has_certificate == 1 ? 'block' : 'none'};">
<h4 style="font-weight:700; color:var(--primary-dark); margin-bottom:16px; font-size:1.05rem;">⚙️ ตั้งค่าเกียรติบัตรเฉพาะแบบประเมิน</h4>
<div class="form-group mb-20">
<label class="form-label" style="font-weight:700;">รูปภาพเทมเพลตเกียรติบัตร (กระดาษ A4 แนวนอน แนะนำไฟล์ PNG ขนาดประมาณ 2970x2100px)</label>
<div style="display:flex; gap:16px; align-items:center; margin-top:8px;">
<input type="file" id="edit-survey-cert-file" accept="image/png, image/jpeg" style="display:none;" onchange="uploadCertTemplate(this)">
<button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('edit-survey-cert-file').click()">
📤 อัปโหลดรูปภาพเทมเพลต
</button>
<span id="cert-upload-status" style="font-size:0.85rem; color:#6b7280;">
${currentSurvey.certificate_template ? 'ใช้รูปภาพเทมเพลตที่อัปโหลดไว้แล้ว' : 'ใช้เทมเพลตเริ่มต้นของระบบ'}
</span>
${currentSurvey.certificate_template ? `<button type="button" id="cert-delete-btn" class="btn btn-ghost btn-sm text-red-500" onclick="resetCertTemplate()" style="color:#ef4444; border:1px solid #ef4444; padding:2px 8px; margin-left:10px;">ลบรูปภาพ</button>` : ''}
</div>
<input type="hidden" id="edit-survey-cert-template-path" value="${escapeHtml(currentSurvey.certificate_template || '')}">
<div id="cert-preview-wrapper" style="margin-top:12px; display:${currentSurvey.certificate_template ? 'block' : 'none'};">
<img id="cert-template-preview" src="${currentSurvey.certificate_template ? (window.location.pathname.includes('/survey') ? '/survey/' : '/') + currentSurvey.certificate_template : ''}" style="max-height:140px; width:auto; border-radius:8px; border:1px solid var(--border-color); box-shadow:var(--shadow-sm);">
</div>
</div>
<div style="display:flex; gap:24px; flex-wrap:wrap; margin-top:16px;">
<div class="form-group" style="flex:1; min-width:200px;">
<label class="form-label" style="font-weight:700;">ตำแหน่งชื่อแนวตั้ง (Name Y-Position)</label>
<div style="display:flex; align-items:center; gap:8px; margin-top:8px;">
<input type="number" step="0.1" id="edit-survey-cert-name-y" class="form-input" style="width:110px;" value="${currentSurvey.cert_name_y || 82.5}" oninput="markDirty()">
<span style="font-size:0.9rem; color:#4b5563;">มิลลิเมตร (mm)</span>
</div>
<small style="color:#6b7280; display:block; margin-top:4px;">(ค่าปกติคือ 82.5 มม.) วัดจากขอบบนสุดของกระดาษ</small>
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label class="form-label" style="font-weight:700;">ตำแหน่งรหัสตรวจสอบแนวตั้ง (Code Y-Position)</label>
<div style="display:flex; align-items:center; gap:8px; margin-top:8px;">
<input type="number" step="0.1" id="edit-survey-cert-code-y" class="form-input" style="width:110px;" value="${currentSurvey.cert_code_y || 190.5}" oninput="markDirty()">
<span style="font-size:0.9rem; color:#4b5563;">มิลลิเมตร (mm)</span>
</div>
<small style="color:#6b7280; display:block; margin-top:4px;">(ค่าปกติคือ 190.5 มม.) วัดจากขอบบนสุดของกระดาษ</small>
</div>
</div>
</div>
</div>
</div>
<div id="sectionsContainer"></div>
`;
document.getElementById('editorContent').innerHTML = html;
const container = document.getElementById('sectionsContainer');
(currentSurvey.sections || []).forEach((section, si) => {
container.insertAdjacentHTML('beforeend', buildSectionUI(si, section));
});
}
/* ============================================================
SECTION
============================================================ */
function buildSectionUI(si, section) {
// Build headings HTML
let headingsHTML = '';
(section.headings || []).forEach((heading, hi) => {
headingsHTML += buildHeadingUI(si, hi, heading);
});
// Build loose questions (not in any heading) HTML
let looseQuestionsHTML = '';
(section.questions || []).forEach((q, qi) => {
looseQuestionsHTML += buildQuestionUI(si, null, qi, q);
});
return `
<div class="card mb-40" data-section="${si}" style="border:none; box-shadow:var(--shadow-md); overflow:visible;">
<div class="card-header" style="background:var(--bg-secondary); border-radius:var(--radius-xl) var(--radius-xl) 0 0; padding:24px 32px;">
<div class="d-flex items-center gap-16 flex-1">
<div style="flex:1;">
<input type="text" class="form-input section-title-input" style="font-weight:800;font-size:24px;border:none;padding:4px 0;background:transparent; color:var(--primary-dark);"
value="${escapeHtml(section.title)}" oninput="markDirty()">
</div>
<select class="form-input section-category-select" style="width:auto;font-size:14px;padding:6px 12px;border:1px solid var(--border-color); border-radius:var(--radius-md); background:var(--bg-card);"
onchange="markDirty()">
<option value="general" ${section.category==='general'?'selected':''}>📊 ข้อมูลทั่วไป (General)</option>
<option value="rating" ${section.category==='rating'?'selected':''}>⭐ การประเมิน (Rating)</option>
<option value="suggestions" ${section.category==='suggestions'?'selected':''}>📝 ข้อเสนอแนะ (Suggestions)</option>
</select>
</div>
<button type="button" class="btn btn-ghost btn-sm text-danger" onclick="removeSection(this)" style="margin-left:16px;">🗑️ ลบส่วนนี้</button>
</div>
<div class="card-body" style="padding:32px;">
<!-- Headings Container -->
<div class="headings-container" data-section-headings="${si}">${headingsHTML}</div>
<!-- Loose Questions Container (questions not in any heading) -->
<div class="loose-questions-container" data-section-loose="${si}">
${looseQuestionsHTML ? `<div class="loose-questions-label" style="font-size:13px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin:16px 0 12px;padding-bottom:8px;border-bottom:1px dashed var(--border-color);">📋 คำถามทั่วไป (ไม่อยู่ในหัวข้อย่อย)</div>` : ''}
${looseQuestionsHTML}
</div>
<div class="section-actions-bar" style="display:flex;gap:12px;margin-top:24px;">
<button type="button" class="btn btn-ghost" style="border:2px dashed var(--primary-color); flex:1; border-radius:var(--radius-lg); color:var(--primary-color); font-weight:700;"
onclick="addHeading(this)">📑 เพิ่มหัวข้อย่อย</button>
<button type="button" class="btn btn-ghost" style="border:2px dashed var(--border-color); flex:1; border-radius:var(--radius-lg);"
onclick="addLooseQuestion(this)">+ เพิ่มคำถามทั่วไป</button>
</div>
</div>
</div>
`;
}
function addSection() {
const si = document.querySelectorAll('[data-section]').length;
const html = buildSectionUI(si, { title: `ส่วนที่ ${si+1}`, category: 'rating', headings: [], questions: [] });
document.getElementById('sectionsContainer').insertAdjacentHTML('beforeend', html);
markDirty();
}
function removeSection(btn) {
if (!confirm('ยืนยันลบส่วนนี้พร้อมหัวข้อย่อยและคำถามทั้งหมด?')) return;
btn.closest('.card').remove();
markDirty();
}
/* ============================================================
HEADING (หัวข้อย่อย)
============================================================ */
function buildHeadingUI(si, hi, heading = {}) {
let questionsHTML = '';
(heading.questions || []).forEach((q, qi) => {
questionsHTML += buildQuestionUI(si, hi, qi, q);
});
return `
<div class="heading-block" data-heading="${hi}" style="margin-bottom:28px; border:2px solid var(--primary-color); border-radius:var(--radius-lg); background:linear-gradient(135deg, rgba(37,99,235,0.03), rgba(37,99,235,0.06)); overflow:visible;">
<div class="heading-header" style="padding:16px 20px; background:linear-gradient(135deg, rgba(37,99,235,0.08), rgba(37,99,235,0.12)); border-bottom:1px solid rgba(37,99,235,0.15); display:flex; align-items:center; gap:12px;">
<span style="font-size:20px;">📑</span>
<div style="flex:1;">
<input type="text" class="form-input heading-title-input" style="font-weight:700;font-size:18px;border:none;padding:4px 0;background:transparent; color:var(--primary-dark);"
value="${escapeHtml(heading.title || '')}" placeholder="ชื่อหัวข้อย่อย..." oninput="markDirty()">
<input type="text" class="form-input heading-desc-input" style="font-size:13px;border:none;padding:2px 0;background:transparent; color:var(--text-muted);"
value="${escapeHtml(heading.description || '')}" placeholder="คำอธิบายหัวข้อ (ไม่บังคับ)" oninput="markDirty()">
</div>
<button type="button" class="btn btn-ghost btn-sm text-danger" onclick="removeHeading(this)" title="ลบหัวข้อย่อย">🗑️</button>
</div>
<div class="heading-questions" style="padding:16px 20px;">
<div class="heading-questions-list">${questionsHTML}</div>
<button type="button" class="btn btn-ghost btn-sm" style="border:1px dashed var(--border-color); width:100%; border-radius:var(--radius-md); margin-top:12px;"
onclick="addQuestionToHeading(this)">+ เพิ่มคำถามในหัวข้อนี้</button>
</div>
</div>
`;
}
function addHeading(btn) {
const sectionEl = btn.closest('[data-section]');
const headingsContainer = sectionEl.querySelector('.headings-container');
const hi = headingsContainer.querySelectorAll('.heading-block').length;
const si = parseInt(sectionEl.dataset.section);
const html = buildHeadingUI(si, hi, { title: '', description: '', questions: [] });
headingsContainer.insertAdjacentHTML('beforeend', html);
// Focus on the title input
const newHeading = headingsContainer.lastElementChild;
const titleInput = newHeading.querySelector('.heading-title-input');
if (titleInput) titleInput.focus();
markDirty();
}
function removeHeading(btn) {
if (!confirm('ยืนยันลบหัวข้อย่อยนี้พร้อมคำถามทั้งหมด?')) return;
btn.closest('.heading-block').remove();
markDirty();
}
/* ============================================================
QUESTION
============================================================ */
function buildQuestionUI(si, hi, qi, q = {}) {
const qType = q.question_type || 'radio';
const qText = q.question_text || '';
const qDescription = q.description || '';
const qOptions = Array.isArray(q.options) ? q.options.join('\n') : '';
const showOptions = ['radio', 'checkbox', 'dropdown'].includes(qType);
// Context label: which heading or "ทั่วไป"
const contextLabel = hi !== null ? `หัวข้อ ${hi+1}` : '';
return `
<div class="question-builder mb-24" style="border-left:4px solid var(--primary-color);">
<div class="qb-header">
<span class="qb-handle">⋮⋮</span>
<span class="text-md fw-800 q-number-label" style="color:var(--primary-dark); font-size:1.1rem;">คำถาม</span>
${contextLabel ? `<span style="font-size:11px;color:var(--text-muted);margin-left:8px;">(${contextLabel})</span>` : ''}
<div class="qb-actions">
<button type="button" class="btn btn-ghost btn-sm text-danger" onclick="removeQuestion(this)" title="ลบคำถาม">🗑️ ลบ</button>
</div>
</div>
<div class="form-group mb-16">
<label class="form-label">คำถาม / หัวข้อ</label>
<input type="text" class="form-input q-text-input" value="${escapeHtml(qText)}" placeholder="พิมพ์คำถามที่นี่..." oninput="markDirty()">
</div>
<div class="form-group mb-16">
<label class="form-label">รายละเอียดเพิ่มเติม (คำชี้แจง/คำใบ้ - ไม่บังคับ)</label>
<input type="text" class="form-input q-desc-input" value="${escapeHtml(qDescription)}" placeholder="เช่น กรุณาระบุรหัสนักศึกษา..." oninput="markDirty()">
</div>
<div class="grid grid-2 gap-24 items-end">
<div class="form-group">
<label class="form-label">ประเภทการตอบ</label>
<select class="form-input q-type-select" onchange="toggleOptions(this);markDirty();">
<option value="radio" ${qType==='radio'?'selected':''}>📊 ระดับ 1-5 (Radio)</option>
<option value="text" ${qType==='text'?'selected':''}>📝 พิมพ์ตอบ (Short Text)</option>
<option value="dropdown" ${qType==='dropdown'?'selected':''}>📋 รายการเลือก (Dropdown)</option>
<option value="checkbox" ${qType==='checkbox'?'selected':''}>☑️ เลือกได้หลายข้อ (Checkbox)</option>
</select>
</div>
<div class="form-group q-options-group" style="${showOptions ? '' : 'display:none;'}">
<label class="form-label">ตัวเลือก (1 บรรทัดต่อ 1 ข้อ)</label>
<textarea class="form-input q-options-input" placeholder="ตัวเลือก 1\nตัวเลือก 2" rows="2" oninput="markDirty()">${escapeHtml(qOptions)}</textarea>
</div>
</div>
</div>
`;
}
function addQuestionToHeading(btn) {
const headingEl = btn.closest('.heading-block');
const listEl = headingEl.querySelector('.heading-questions-list');
const qi = listEl.querySelectorAll('.question-builder').length;
const si = parseInt(headingEl.closest('[data-section]').dataset.section);
const hi = parseInt(headingEl.dataset.heading);
const html = buildQuestionUI(si, hi, qi);
listEl.insertAdjacentHTML('beforeend', html);
reindexQuestions(listEl);
markDirty();
}
function addLooseQuestion(btn) {
const sectionEl = btn.closest('[data-section]');
const looseContainer = sectionEl.querySelector('.loose-questions-container');
const si = parseInt(sectionEl.dataset.section);
const qi = looseContainer.querySelectorAll('.question-builder').length;
// Add label if first loose question
if (qi === 0) {
looseContainer.insertAdjacentHTML('afterbegin',
`<div class="loose-questions-label" style="font-size:13px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin:16px 0 12px;padding-bottom:8px;border-bottom:1px dashed var(--border-color);">📋 คำถามทั่วไป (ไม่อยู่ในหัวข้อย่อย)</div>`
);
}
const html = buildQuestionUI(si, null, qi);
looseContainer.insertAdjacentHTML('beforeend', html);
reindexQuestions(looseContainer);
markDirty();
}
function removeQuestion(btn) {
if (!confirm('ยืนยันลบคำถามนี้?')) return;
const container = btn.closest('.heading-questions-list') || btn.closest('.loose-questions-container');
btn.closest('.question-builder').remove();
if (container) reindexQuestions(container);
markDirty();
}
function reindexQuestions(container) {
if (!container) return;
container.querySelectorAll('.question-builder').forEach((qEl, idx) => {
const label = qEl.querySelector('.q-number-label');
if (label) label.textContent = `คำถาม`;
});
}
function toggleOptions(select) {
const group = select.closest('.question-builder').querySelector('.q-options-group');
if (['radio', 'checkbox', 'dropdown'].includes(select.value)) {
group.style.display = 'block';
} else {
group.style.display = 'none';
}
}
/* ============================================================
SAVE — อ่าน DOM แปลงเป็น JSON แล้วส่ง API
============================================================ */
async function saveSurvey() {
if (!currentSurvey) return;
const title = document.getElementById('edit-survey-title').value;
const description = document.getElementById('edit-survey-desc').value;
const hasCertificate = document.getElementById('edit-survey-cert').checked ? 1 : 0;
const sections = [];
document.querySelectorAll('[data-section]').forEach((sectionEl, si) => {
const sTitle = sectionEl.querySelector('.section-title-input')?.value || `ส่วนที่ ${si+1}`;
const sCategory = sectionEl.querySelector('.section-category-select')?.value || 'rating';
// Collect headings and their questions
const headings = [];
sectionEl.querySelectorAll('.heading-block').forEach((headingEl, hi) => {
const hTitle = headingEl.querySelector('.heading-title-input')?.value || '';
const hDesc = headingEl.querySelector('.heading-desc-input')?.value || '';
const hQuestions = collectQuestions(headingEl.querySelector('.heading-questions-list'));
if (hTitle.trim()) {
headings.push({
title: hTitle.trim(),
description: hDesc.trim() || null,
order_num: hi,
questions: hQuestions
});
}
});
// Collect loose questions (not in any heading)
const looseQuestions = collectQuestions(sectionEl.querySelector('.loose-questions-container'));
sections.push({
section_number: si + 1,
title: sTitle,
category: sCategory,
headings: headings,
questions: looseQuestions
});
});
try {
const btn = document.querySelector('.btn-primary');
btn.disabled = true;
btn.textContent = '⌛ กำลังบันทึก...';
const certificateTemplate = document.getElementById('edit-survey-cert-template-path').value || null;
const certNameY = parseFloat(document.getElementById('edit-survey-cert-name-y').value || 82.5);
const certCodeY = parseFloat(document.getElementById('edit-survey-cert-code-y').value || 190.5);
await API.post('surveys.php?action=update', {
id: currentSurvey.id,
title,
description,
has_certificate: hasCertificate,
certificate_template: certificateTemplate,
cert_name_y: certNameY,
cert_code_y: certCodeY,
sections
});
Toast.success('บันทึกสำเร็จ');
isDirty = false;
} catch (err) {
Toast.error(err.message);
} finally {
const btn = document.querySelector('.btn-primary');
btn.disabled = false;
btn.textContent = '💾 บันทึกการเปลี่ยนแปลง';
}
}
function collectQuestions(container) {
if (!container) return [];
const questions = [];
container.querySelectorAll(':scope > .question-builder').forEach((qEl, qi) => {
const qText = qEl.querySelector('.q-text-input')?.value || '';
const qDescription = qEl.querySelector('.q-desc-input')?.value || '';
const qType = qEl.querySelector('.q-type-select')?.value || 'radio';
const qOptionsRaw = qEl.querySelector('.q-options-input')?.value || '';
const options = qOptionsRaw.split('\n').map(o => o.trim()).filter(o => o !== '');
if (qText.trim()) {
questions.push({
question_text: qText.trim(),
description: qDescription.trim() || null,
question_type: qType,
order_num: qi,
is_required: 1,
options: options.length > 0 ? options : null
});
}
});
return questions;
}
/* ============================================================
UTILITY FUNCTIONS
============================================================ */
function markDirty() {
isDirty = true;
}
function closeEditor() {
if (isDirty) {
if (!confirm('มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก ยืนยันปิดหรือไม่?')) return;
}
window.close();
}
function previewSurvey() {
if (!currentSurvey) return;
window.open(`respond.php?link=${currentSurvey.unique_link}`, '_blank');
}
// Warn before leave if dirty
window.addEventListener('beforeunload', (e) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
});
function toggleCertPanel() {
const isChecked = document.getElementById('edit-survey-cert').checked;
const panel = document.getElementById('cert-config-panel');
if (panel) {
panel.style.display = isChecked ? 'block' : 'none';
}
}
async function uploadCertTemplate(input) {
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
const statusEl = document.getElementById('cert-upload-status');
statusEl.textContent = '⌛ กำลังอัปโหลดไฟล์...';
statusEl.style.color = '#2563eb';
const formData = new FormData();
formData.append('template_image', file);
try {
const basePath = window.location.pathname.includes('/survey') ? '/survey' : '';
const res = await fetch(`${basePath}/api/surveys.php?action=uploadTemplate`, {
method: 'POST',
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'การอัปโหลดล้มเหลว');
document.getElementById('edit-survey-cert-template-path').value = data.template_path;
statusEl.textContent = '✅ อัปโหลดเทมเพลตสำเร็จ';
statusEl.style.color = '#16a34a';
// Update preview
const previewImg = document.getElementById('cert-template-preview');
const previewWrapper = document.getElementById('cert-preview-wrapper');
previewImg.src = (basePath ? `${basePath}/` : '/') + data.template_path;
previewWrapper.style.display = 'block';
// Add dynamic delete button if it doesn't exist
if (!document.getElementById('cert-delete-btn')) {
const btnHTML = `<button type="button" id="cert-delete-btn" class="btn btn-ghost btn-sm text-red-500" onclick="resetCertTemplate()" style="color:#ef4444; border:1px solid #ef4444; padding:2px 8px; margin-left:10px;">ลบรูปภาพ</button>`;
input.parentElement.insertAdjacentHTML('beforeend', btnHTML);
}
markDirty();
} catch (err) {
statusEl.textContent = '❌ ' + err.message;
statusEl.style.color = '#dc2626';
}
}
function resetCertTemplate() {
document.getElementById('edit-survey-cert-template-path').value = '';
document.getElementById('cert-upload-status').textContent = 'ใช้เทมเพลตเริ่มต้นของระบบ';
document.getElementById('cert-upload-status').style.color = '#6b7280';
document.getElementById('cert-preview-wrapper').style.display = 'none';
document.getElementById('cert-template-preview').src = '';
document.getElementById('edit-survey-cert-file').value = '';
const deleteBtn = document.getElementById('cert-delete-btn');
if (deleteBtn) deleteBtn.remove();
markDirty();
}
document.addEventListener('DOMContentLoaded', initEditor);