Lompat ke konten Lompat ke sidebar Lompat ke footer

Source Code Aplikasi Cetak Kartu Ujian Menggunakan Google Apps Script

Apa Itu Ujian Formatif dan Sumatif?

Dalam dunia pendidikan, evaluasi adalah hal yang mutlak. Secara umum, evaluasi dibagi menjadi dua jenis utama, yaitu Ujian Formatif dan Ujian Sumatif.

  • Ujian Formatif: Adalah evaluasi yang dilakukan selama proses pembelajaran berlangsung. Tujuannya bukan untuk memberikan nilai akhir, melainkan untuk memantau perkembangan belajar siswa, memberikan umpan balik (feedback), dan memperbaiki metode pengajaran guru. Contohnya: kuis harian, tanya jawab di kelas, atau tugas kelompok.
  • Ujian Sumatif: Adalah evaluasi yang dilakukan pada akhir suatu periode pembelajaran (misalnya akhir bab, akhir semester, atau akhir jenjang sekolah). Tujuannya adalah untuk menentukan tingkat keberhasilan siswa dan memberikan nilai akhir (rapor). Contohnya: Ujian Akhir Semester (UAS), Sumatif Akhir Semester (SAS), atau Ujian Nasional.

Google Apps Script dan Manfaatnya untuk Cetak Kartu Ujian

Google Apps Script (GAS) adalah platform *scripting* berbasis cloud dari Google yang memungkinkan kita untuk membuat aplikasi web ringan dan mengotomatiskan tugas-tugas di ekosistem Google Workspace (seperti Google Sheets, Docs, dan Drive). Bahasa pemrograman yang digunakan sangat mirip dengan JavaScript.

Mengapa menggunakan GAS untuk sistem cetak kartu ujian sangat bermanfaat?

  • Gratis & Berbasis Cloud: Anda tidak perlu menyewa hosting atau domain berbayar. Cukup dengan akun Google biasa.
  • Database Super Mudah: Menggunakan Google Sheets (Excel-nya Google) sebagai database. Sangat familiar bagi operator sekolah untuk mengelola data siswa.
  • Mudah Dibagikan: Aplikasinya berupa link web (URL) yang bisa diakses dari perangkat mana saja (PC/Laptop/HP) tanpa perlu menginstal aplikasi tambahan.
  • Kustomisasi Tinggi: Tampilan bisa dibuat sangat profesional menggunakan HTML dan CSS murni, menjamin hasil cetakan (print) tidak blur/pecah.

Langkah-langkah Membuat Aplikasi Sistem Kartu Ujian

  1. Buka Google Apps Script menggunakan akun Google (Gmail) Anda.
  2. Klik tombol "Proyek Baru" (New Project) di kiri atas.
  3. Anda akan melihat file bernama Code.gs. Salin kode Backend (Code.gs) di bawah ini dan tempelkan ke file tersebut.
  4. Tambahkan file baru dengan cara klik ikon (+) Tambahkan File > HTML. Beri nama file tersebut persis dengan: Index (tanpa .html).
  5. Salin kode Frontend (Index.html) di bawah ini dan tempelkan ke file Index yang baru dibuat.
  6. Klik Simpan (ikon disket), lalu klik tombol biru "Terapkan" (Deploy) > "Penerapan Baru" (New Deployment).
  7. Pilih jenis "Aplikasi Web" (Web App). Atur "Siapa yang memiliki akses" menjadi "Siapa saja" (Anyone), lalu klik Terapkan.
  8. Selesai! Buka URL Web App yang muncul, dan aplikasi Anda siap digunakan.

1. Kode Backend (Code.gs)

Code.gs
/**
 * Code.gs
 * Backend untuk Web App Kartu Ujian (Spreadsheet Database Edition)
 */

function doGet(e) {
  return HtmlService.createTemplateFromFile('Index')
    .evaluate()
    .setTitle('Cetak Kartu Ujian - Platinum Edition')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

// ==========================================
// KONFIGURASI DATABASE SPREADSHEET
// ==========================================
const SHEET_CONFIG = "Konfigurasi";
const SHEET_SISWA = "DataSiswa";

function getDatabase() {
  const props = PropertiesService.getScriptProperties();
  let dbId = props.getProperty('DB_ID');
  let ss;

  if (dbId) {
    try {
      ss = SpreadsheetApp.openById(dbId);
    } catch (e) {
      dbId = null; 
    }
  }

  if (!dbId) {
    ss = SpreadsheetApp.create("Database_KartuUjian_Webapp");
    props.setProperty('DB_ID', ss.getId());
    ss.insertSheet(SHEET_CONFIG);
    ss.insertSheet(SHEET_SISWA);
    
    let sheet1 = ss.getSheetByName("Sheet1") || ss.getSheetByName("Lembar1");
    if(sheet1) ss.deleteSheet(sheet1);
  }

  return ss;
}

/**
 * API: Menyimpan atau Menambah data dari Frontend ke Spreadsheet
 */
function saveToDatabase(configData, studentsArray, mode = "overwrite") {
  try {
    const ss = getDatabase();
    
    // 1. Simpan Konfigurasi (Format Key-Value)
    let shConfig = ss.getSheetByName(SHEET_CONFIG);
    if (!shConfig) shConfig = ss.insertSheet(SHEET_CONFIG);
    shConfig.clear();
    
    let configRows = [["Parameter", "Nilai"]]; 
    for (let key in configData) {
      configRows.push([key, String(configData[key] || "")]);
    }
    shConfig.getRange(1, 1, configRows.length, 2).setValues(configRows);
    shConfig.getRange(1, 1, 1, 2).setFontWeight("bold").setBackground("#d9ead3");
    shConfig.setColumnWidth(1, 150);
    shConfig.setColumnWidth(2, 400);

    // 2. Simpan / Tambah Data Siswa
    let shSiswa = ss.getSheetByName(SHEET_SISWA);
    if (!shSiswa) {
       shSiswa = ss.insertSheet(SHEET_SISWA);
       mode = "overwrite"; // Paksa overwrite jika sheet baru
    }
    
    let startRow = 2;
    let startNo = 1;

    // Logika Overwrite vs Append (Agar bisa simpan banyak kelas)
    if (mode === "overwrite") {
      shSiswa.clear();
      shSiswa.getRange(1, 1, 1, 4).setValues([["No", "Nama Siswa", "Kelas", "Ruang"]])
             .setFontWeight("bold").setBackground("#cfe2f3");
      shSiswa.setColumnWidth(2, 250);
    } else {
      // Mode Append (Tambah ke baris bawah)
      startRow = shSiswa.getLastRow() + 1;
      if(startRow > 2) {
         startNo = Number(shSiswa.getRange(startRow - 1, 1).getValue()) + 1;
      } else {
         shSiswa.getRange(1, 1, 1, 4).setValues([["No", "Nama Siswa", "Kelas", "Ruang"]])
                .setFontWeight("bold").setBackground("#cfe2f3");
         shSiswa.setColumnWidth(2, 250);
      }
    }
    
    let siswaRows = [];
    studentsArray.forEach((siswa, index) => {
      siswaRows.push([
        startNo + index, 
        String(siswa.nama || ""), 
        String(siswa.kelas || configData.kelas || ""), 
        String(siswa.ruang || configData.ruang || "")
      ]);
    });
    
    if (siswaRows.length > 0) {
      shSiswa.getRange(startRow, 1, siswaRows.length, 4).setValues(siswaRows);
    }

    return { success: true, dbUrl: ss.getUrl() };
  } catch (err) {
    throw new Error("Gagal menyimpan ke Database: " + err.message);
  }
}

/**
 * API: Mengambil seluruh data dari Spreadsheet
 */
function loadFromDatabase() {
  try {
    const ss = getDatabase();
    let shConfig = ss.getSheetByName(SHEET_CONFIG);
    let shSiswa = ss.getSheetByName(SHEET_SISWA);
    
    let configData = {};
    if (shConfig) {
      let data = shConfig.getDataRange().getDisplayValues();
      for (let i = 1; i < data.length; i++) {
        if(data[i][0]) configData[data[i][0]] = data[i][1];
      }
    }
    
    let studentsData = [];
    if (shSiswa) {
      let data = shSiswa.getDataRange().getDisplayValues();
      for (let i = 1; i < data.length; i++) {
        if(data[i][1]) {
          studentsData.push({
            nama: data[i][1],
            kelas: data[i][2],
            ruang: data[i][3]
          });
        }
      }
    }
    
    return {
      config: configData,
      students: studentsData,
      dbUrl: ss.getUrl()
    };
  } catch (err) {
    throw new Error("Gagal memuat dari Database: " + err.message);
  }
}

2. Kode Frontend (Index.html)

Index.html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Sistem Kartu Ujian</title>
  
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
  <link href="https://fonts.googleapis.com/css2?family=Noto+Serif:wght@400;700&family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet">
  <!-- Pustaka QR Code -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>

  <style>
    /* --- CSS GLOBAL & RESPONSIVE --- */
    body {
      font-family: 'Noto Sans', sans-serif;
      background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%); 
      min-height: 100vh;
      color: #000;
    }

    /* --- NAVBAR APP BERWARNA (EFEK TIMBUL) --- */
    .app-navbar {
      background: linear-gradient(135deg, #f0f3f8 0%, #ffffff 40%, #d8e0e8 100%);
      box-shadow: inset 0px 2px 4px rgba(255, 255, 255, 0.9), 0 6px 15px rgba(0,0,0,0.15);
      border: 1px solid #b3c0cc;
      border-bottom: 4px solid #4a6582;
      border-radius: 12px;
      padding: 15px 20px;
      margin: 15px auto 25px auto;
      max-width: 1200px;
    }
    .app-navbar h5 { color: #2c3e50 !important; text-shadow: 1px 1px 0px rgba(255,255,255,0.8); }
    
    .nav-pills .nav-link {
      color: #5a5f66; font-weight: 600; border-radius: 8px; margin-right: 10px;
      transition: all 0.3s ease; cursor: pointer; border: 1px solid transparent;
    }
    .nav-pills .nav-link:hover { background-color: #e2e5e9; }
    .nav-pills .nav-link.active {
      background: linear-gradient(to right, #2c3e50, #1a252f); color: white;
      box-shadow: 0 4px 8px rgba(0,0,0,0.25); border: 1px solid #10171d;
    }

    /* --- PANEL KONFIGURASI --- */
    .config-panel {
      background: white; padding: 25px; border-radius: 16px;
      box-shadow: 0 10px 30px rgba(0,0,0,0.08); margin: 0 auto 30px auto;
      max-width: 1000px; border: 1px solid #dee2e6;
    }

    .form-label { font-size: 0.85rem; font-weight: 700; color: #4a4e52; margin-bottom: 4px; }
    .form-control, .form-select { background-color: #f8f9fa; border: 1px solid #ced4da; border-radius: 6px; }
    .form-control:focus, .form-select:focus { border-color: #4a6582; box-shadow: 0 0 0 0.2rem rgba(74, 101, 130, 0.25); background-color: #fff; }

    .btn-save { background: linear-gradient(to bottom, #198754, #146c43); color: white; font-weight: bold; border: none; border-radius: 8px; }
    .btn-danger-custom { background: linear-gradient(to bottom, #dc3545, #b02a37); color: white; font-weight: bold; border: none; border-radius: 8px; }
    .btn-print { background: linear-gradient(to bottom, #0d6efd, #0b5ed7); color: white; font-weight: bold; border: none; border-radius: 8px;}

    /* --- LAYOUT KARTU (CARD) - 8 KARTU PER A4 --- */
    .card-container {
      width: 210mm; margin: 0 auto; background: white; padding: 5mm 0; 
      box-shadow: 0 10px 25px rgba(0,0,0,0.2); min-height: 297mm; box-sizing: border-box;
      display: grid; grid-template-columns: repeat(2, 9.8cm); justify-content: center; 
      grid-auto-rows: max-content; gap: 3mm 4mm; 
    }

    /* KARTU INDIVIDUAL - PRESISI TINGGI */
    .exam-card {
      position: relative; width: 9.8cm; height: 6.5cm; 
      border: 3px double #000; padding: 4px 8px; box-sizing: border-box;
      font-family: 'Noto Serif', serif; overflow: hidden; background: white;
      page-break-inside: avoid; display: flex; flex-direction: column;
    }

    /* WATERMARK PROFESIONAL TENGAH */
    .watermark-container {
      position: absolute; top: 52%; left: 50%; transform: translate(-50%, -50%);
      width: 3.5cm; height: 3.5cm; z-index: 0; pointer-events: none;
      display: flex; align-items: center; justify-content: center;
    }
    .watermark-img { width: 100%; height: 100%; opacity: 0.1; object-fit: contain; }

    /* KOP SURAT */
    .card-header-area { display: flex; justify-content: space-between; align-items: center; position: relative; z-index: 1; flex-shrink: 0; }
    .logo-box { width: 35px; height: 35px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
    .logo-img { max-width: 100%; max-height: 100%; object-fit: contain; }

    .header-text { flex: 1; text-align: center; line-height: 1.1; padding: 0 4px; }
    .header-text .gov-text { margin: 0; font-size: 8pt; font-weight: bold; text-transform: uppercase; }
    .header-text .dinas-text { margin: 1px 0; font-size: 8pt; font-weight: bold; text-transform: uppercase; }
    .header-text .school-text { margin: 0; font-size: 8pt; font-weight: bold; text-transform: uppercase; }
    .header-text p { margin: 0; font-size: 5.5pt; }

    .header-divider { border-top: 2.5px solid #000; margin: 2px 0 4px 0; position: relative; z-index: 1; }

    /* JUDUL KARTU (1 BARIS PENUH) */
    .card-title-box { text-align: center; margin: 0 0 3px 0; position: relative; z-index: 1; flex-shrink: 0; line-height: 1.1;}
    .card-title-text { font-size: 7.5pt; font-weight: bold; text-decoration: underline; text-transform: uppercase; white-space: nowrap; }
    .card-subtitle-text { font-size: 6pt; font-weight: bold; }

    /* DATA SISWA */
    .card-body-area { position: relative; z-index: 1; font-size: 8pt; flex-grow: 0; padding-left: 2px; margin-bottom: 2px; }
    .data-row { display: flex; margin-bottom: 2px; align-items: baseline; }
    .data-label { width: 68px; font-weight: bold; }
    .data-separator { width: 8px; text-align: center; font-weight: bold; }
    .data-value { flex: 1; font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

    /* FOOTER (FOTO & TANDA TANGAN SEJAJAR) */
    .card-footer-area { 
      margin-top: 2px; 
      display: flex; 
      justify-content: space-between; 
      align-items: flex-start;
      position: relative; 
      z-index: 1; 
    }
    
    .footer-left {
      display: flex;
      flex-direction: row; 
      align-items: flex-start;
      gap: 6px;
    }

    .qrcode-box { 
      width: 1.8cm; 
      height: 1.8cm; 
      display: flex; 
      align-items: center; 
      justify-content: center; 
      margin-top: 2px;
    }
    .qrcode-box img { max-width: 100%; max-height: 100%; }

    .photo-box { 
      width: 1.8cm; 
      height: 2.3cm; 
      border: 1px solid #000; 
      display: flex; 
      align-items: center; 
      justify-content: center; 
      font-size: 6pt; 
      color: #666; 
      background: #f0f0f0; 
    }

    /* Tanda Tangan: Sejajar atas dan bawah kotak foto dengan Flexbox Space-Between */
    .signature-box { 
      text-align: left; 
      font-size: 7.5pt; 
      width: 4.5cm; 
      line-height: 1.1; 
      display: flex;
      flex-direction: column;
      justify-content: space-between; /* Mendorong NIP sejajar ke garis bawah foto */
      height: 2.3cm; /* Tinggi sama persis dengan tinggi pas foto */
    }
    
    .kepsek-name { font-weight: bold; text-decoration: underline;} 

    /* --- NOTIFIKASI OVERLAY --- */
    .fullscreen-overlay {
      position: fixed; top: 0; left: 0; width: 100%; height: 100%;
      z-index: 10000; display: none; flex-direction: column; align-items: center; justify-content: center;
      color: white; text-align: center; animation: fadeIn 0.2s ease-in-out;
    }
    @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
    #loadingOverlay { background: rgba(255,255,255,0.9); color: #333; }
    #successOverlay { background: rgba(25, 135, 84, 0.95); } 
    #errorOverlay { background: rgba(220, 53, 69, 0.95); } 

    /* --- PRINT SETTINGS --- */
    @media print {
      @page { size: A4 portrait; margin: 0.5cm; } 
      body { margin: 0; background: white; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
      .app-navbar, .config-panel, .print-controls, .fullscreen-overlay { display: none !important; }
      #menu-cetak { display: block !important; }
      .card-container { 
        width: 100%; margin: 0; box-shadow: none; padding: 0; 
        display: grid; gap: 3mm 4mm; justify-content: center; align-content: start;
      }
      .exam-card { border: 3px double #000 !important; height: 6.5cm !important; page-break-inside: avoid; }
      .photo-box { background-color: #f0f0f0 !important; }
    }
  </style>
</head>
<body>

  <!-- OVERLAYS UNTUK NOTIFIKASI & LOADING -->
  <div id="loadingOverlay" class="fullscreen-overlay">
    <div class="spinner-border text-primary" style="width: 4rem; height: 4rem;" role="status"></div>
    <h4 class="mt-4 fw-bold" id="loadingText">Menyinkronkan data...</h4>
  </div>

  <div id="successOverlay" class="fullscreen-overlay">
    <div style="font-size: 6rem; margin-bottom: 10px; line-height: 1;">✅</div>
    <h1 class="fw-bold text-white">BERHASIL!</h1>
    <h5 id="successText" class="text-white">Aksi selesai.</h5>
  </div>

  <div id="errorOverlay" class="fullscreen-overlay">
    <div style="font-size: 6rem; margin-bottom: 10px; line-height: 1;">❌</div>
    <h1 class="fw-bold text-white">PERHATIAN</h1>
    <h5 id="errorText" class="text-white">Terjadi kesalahan.</h5>
    <button class="btn btn-light mt-4 px-4 fw-bold" onclick="document.getElementById('errorOverlay').style.display='none'">TUTUP</button>
  </div>

  <!-- NAVBAR / TABS -->
  <div class="app-navbar d-flex flex-wrap justify-content-between align-items-center">
    <div class="mb-2 mb-md-0 d-flex align-items-center">
      <span style="font-size: 1.8rem; margin-right: 12px;">🎓</span>
      <div>
        <h5 class="mb-0 fw-bold">Sistem Kartu Ujian</h5>
      </div>
    </div>
    <ul class="nav nav-pills" id="appTabs" role="tablist">
      <li class="nav-item" role="presentation">
        <a class="nav-link active shadow-sm" id="tab-config" onclick="switchTab('config')">⚙️ 1. Konfigurasi</a>
      </li>
      <li class="nav-item" role="presentation">
        <a class="nav-link shadow-sm" id="tab-siswa" onclick="switchTab('siswa')">👥 2. Data Siswa</a>
      </li>
      <li class="nav-item" role="presentation">
        <a class="nav-link shadow-sm" id="tab-cetak" onclick="switchTab('cetak')">🖨️ 3. Cetak Kartu</a>
      </li>
    </ul>
  </div>

  <!-- MENU 1: KONFIGURASI -->
  <div class="container" id="menu-config">
    <div class="config-panel">
      
      <form id="configForm">
        <!-- Identitas Sekolah -->
        <h6 class="text-secondary fw-bold border-bottom pb-2 mb-3">A. Identitas Lembaga & Logo</h6>
        <div class="row g-3 mb-4">
          <div class="col-md-4">
            <label class="form-label">Teks Pemerintah (Baris 1)</label>
            <input type="text" class="form-control" id="inpGov" value="PEMERINTAH KABUPATEN KERINCI">
          </div>
          <div class="col-md-4">
            <label class="form-label">Teks Dinas (Baris 2)</label>
            <input type="text" class="form-control" id="inpDinas" value="DINAS PENDIDIKAN">
          </div>
          <div class="col-md-4">
            <label class="form-label">Nama Sekolah (Baris 3)</label>
            <input type="text" class="form-control" id="inpSchool" value="SMP NEGERI 3 KERINCI">
          </div>
          <div class="col-md-12">
            <label class="form-label">Alamat Lengkap</label>
            <input type="text" class="form-control" id="inpAddress" value="Jl. Lempur Tengah, Kec. Keliling Danau, Kab. Kerinci">
          </div>
          <div class="col-md-6">
            <label class="form-label">URL Logo Kiri (Daerah)</label>
            <input type="text" class="form-control" id="inpLogoLeft" placeholder="Kosongkan untuk logo default">
          </div>
          <div class="col-md-6">
            <label class="form-label">URL Logo Kanan (Sekolah & Watermark Tengah)</label>
            <input type="text" class="form-control" id="inpLogoRight" placeholder="Kosongkan untuk logo default">
          </div>
        </div>

        <!-- Tanda Tangan -->
        <h6 class="text-secondary fw-bold border-bottom pb-2 mb-3">B. Tanda Tangan Pejabat</h6>
        <div class="row g-3 mb-4">
          <div class="col-md-4">
            <label class="form-label">Tempat & Tanggal (Misal: Lempur, 19 Februari 2026)</label>
            <input type="text" class="form-control" id="inpTtdTanggal" value="Lempur, 19 Februari 2026">
          </div>
          <div class="col-md-4">
            <label class="form-label">Nama Kepala Sekolah</label>
            <input type="text" class="form-control" id="inpKepsek" value="Hamdani, S. Pd">
          </div>
          <div class="col-md-4">
            <label class="form-label">NIP Kepala Sekolah</label>
            <input type="text" class="form-control" id="inpNip" value="19800101 200501 1 001">
          </div>
        </div>

        <!-- Pengaturan Ujian -->
        <h6 class="text-secondary fw-bold border-bottom pb-2 mb-3">C. Pengaturan Ujian</h6>
        <div class="row g-3 mb-4">
          <div class="col-md-4">
            <label class="form-label">Nama Ujian (Misal: SUMATIF AKHIR SEMESTER)</label>
            <input type="text" class="form-control" id="inpNamaUjian" value="SUMATIF AKHIR SEMESTER">
          </div>
          <div class="col-md-2">
            <label class="form-label">Semester</label>
            <select class="form-select border-primary fw-bold" id="inpSemester">
              <option value="GANJIL">GANJIL</option>
              <option value="GENAP">GENAP</option>
            </select>
          </div>
          <div class="col-md-3">
            <label class="form-label">Tahun Pelajaran</label>
            <input type="text" class="form-control" id="inpTahun" value="2025/2026">
          </div>
          <div class="col-md-3">
            <label class="form-label" title="Angka di depan nomor ujian (Misal: 25-03)">Awalan No. Ujian</label>
            <input type="text" class="form-control" id="inpPrefixUjian" value="25-03">
          </div>
        </div>
        
        <div class="text-end">
          <button type="button" class="btn btn-save px-5 py-2 shadow" onclick="saveConfigOnly()">
            💾 SIMPAN KONFIGURASI
          </button>
        </div>
      </form>
    </div>
  </div>

  <!-- MENU 3: DATA SISWA -->
  <div class="container" id="menu-siswa" style="display:none;">
    <div class="config-panel">
      
      <!-- PETUNJUK PENGGUNAAN -->
      <div class="alert shadow-sm mb-4" style="background: linear-gradient(to right, #f8f9fa, #e9ecef); border-left: 5px solid #4a6582; color: #333;">
        <h6 class="fw-bold mb-2">📌 Petunjuk Unggah Data Siswa:</h6>
        <ol class="mb-0 small" style="padding-left: 15px; line-height: 1.6;">
          <li>Buka file Excel yang berisi data siswa Anda.</li>
          <li>Pastikan urutan kolom dari kiri ke kanan adalah: <b>Nama Siswa</b> | <b>Kelas</b> | <b>Ruang</b>.</li>
          <li><b>Copy</b> (Blok) baris data dari Excel tersebut, lalu <b>Paste</b> langsung ke dalam kotak putih di bawah ini.</li>
          <li>Klik tombol <b class="text-success">[+] TAMBAH DATA (GABUNG)</b> untuk menyimpannya ke database tanpa menghapus kelas lainnya.</li>
          <li>Ulangi langkah di atas untuk kelas atau ruang lainnya jika perlu.</li>
          <li class="mt-2 text-danger"><b>Catatan:</b> Klik tombol <b>⚠️ HAPUS SEMUA DATA SISWA</b> HANYA jika Anda ingin mereset/mengosongkan seluruh isi Database dari awal.</li>
        </ol>
      </div>
      
      <div class="row g-3">
        <div class="col-12">
          <label class="form-label">Kotak Paste Daftar Nama Siswa (Dari Excel)</label>
          <textarea class="form-control mb-3" id="inpStudents" rows="8" style="resize: none; white-space: pre;" placeholder="Nama Siswa [TAB] Kelas [TAB] Ruang"></textarea>
        </div>
        
        <div class="col-md-6">
          <button type="button" class="btn btn-save w-100 py-3 shadow" onclick="saveStudents('append')">
            ➕ TAMBAH DATA (GABUNG)
          </button>
        </div>
        <div class="col-md-6">
          <button type="button" class="btn btn-danger-custom w-100 py-3 shadow" onclick="saveStudents('overwrite')">
            ⚠️ HAPUS SEMUA DATA SISWA
          </button>
        </div>
      </div>
    </div>
  </div>

  <!-- MENU 4: CETAK KARTU -->
  <div class="container" id="menu-cetak" style="display:none;">
    <!-- Kontrol Cetak -->
    <div class="mb-4 print-controls d-flex flex-wrap justify-content-between align-items-center bg-white p-3 rounded-3 shadow-sm border" style="border-left: 5px solid #0d6efd !important;">
      
      <!-- DROPDOWN KELAS UNTUK CETAK -->
      <div class="d-flex align-items-center gap-2">
        <label class="fw-bold mb-0">Filter Cetak:</label>
        <select id="filterKelasCetak" class="form-select border-primary fw-bold" style="width:160px" onchange="renderFilteredCards()">
          <option value="ALL">Semua Kelas</option>
        </select>
      </div>

      <div class="text-center d-none d-md-block text-muted small">
        Kertas: <b class="text-dark">A4</b> | Margin: <b class="text-dark">Default</b> | Scale: <b class="text-dark">100%</b>
      </div>
      
      <div>
        <button class="btn btn-outline-secondary fw-bold me-2" onclick="forceSyncFromDatabase()">🔄 Sync</button>
        <button class="btn btn-print btn-lg px-4 shadow" onclick="window.print()">🖨️ CETAK SEKARANG</button>
      </div>
    </div>

    <!-- AREA KERTAS A4 -->
    <div id="cardContainer" class="card-container">
      <div class="text-center p-5 text-muted w-100" style="grid-column: 1 / -1;">
        <h4>Memuat Pratinjau...</h4>
      </div>
    </div>
  </div>

  <script>
    // --- GLOBAL DATA ---
    let GLOBAL_CONFIG = {};
    let GLOBAL_STUDENTS = [];

    const MOCK_CONFIG = {
      govText: "PEMERINTAH KABUPATEN KERINCI", dinasText: "DINAS PENDIDIKAN", 
      schoolText: "SMP NEGERI 3 KERINCI", addressText: "Jl. Lempur Tengah, Kec. Keliling Danau, Kab. Kerinci",
      logoLeftUrl: "", logoRightUrl: "", ttdTanggal: "Lempur, 19 Februari 2026", kepsek: "Hamdani, S. Pd", 
      nip: "19800101 200501 1 001", namaUjian: "SUMATIF AKHIR SEMESTER", semester: "GANJIL", 
      tahun: "2025/2026", prefixUjian: "25-03"
    };

    // --- MANAJEMEN CACHE LOCALSTORAGE ---
    window.onload = function() {
        try {
            const cachedConf = localStorage.getItem('kartuUjianConfig_v3');
            const cachedStud = localStorage.getItem('kartuUjianStudents_v3');
            
            if (cachedConf && cachedStud) {
                GLOBAL_CONFIG = JSON.parse(cachedConf);
                GLOBAL_STUDENTS = JSON.parse(cachedStud);
                populateForm(GLOBAL_CONFIG); 
                populateClassFilter(GLOBAL_STUDENTS);
                renderFilteredCards();
            } else {
                populateForm(MOCK_CONFIG);
                forceSyncFromDatabase();
            }
        } catch (e) {
            console.log("Error loading cache", e);
        }
    };

    // --- MANAJEMEN NOTIFIKASI LAYAR PENUH ---
    function showLoading(text) {
      document.getElementById('loadingText').innerText = text;
      document.getElementById('loadingOverlay').style.display = 'flex';
    }
    function hideLoading() {
      document.getElementById('loadingOverlay').style.display = 'none';
    }
    function showSuccess(text) {
      document.getElementById('successText').innerText = text;
      const overlay = document.getElementById('successOverlay');
      overlay.style.display = 'flex';
      setTimeout(() => { overlay.style.display = 'none'; }, 1500); 
    }
    function showError(text) {
      document.getElementById('errorText').innerText = text;
      document.getElementById('errorOverlay').style.display = 'flex';
    }

    // --- TAB NAVIGATION ---
    function switchTab(tabName) {
      ['config', 'siswa', 'cetak'].forEach(t => {
          document.getElementById('menu-' + t).style.display = (t === tabName) ? 'block' : 'none';
          if(t === tabName) {
              document.getElementById('tab-' + t).classList.add('active');
          } else {
              document.getElementById('tab-' + t).classList.remove('active');
          }
      });
      // Beri sedikit jeda agar DOM ter-render sempurna sebelum menggambar QR Code
      if(tabName === 'cetak') {
          setTimeout(() => renderFilteredCards(), 50); 
      }
    }

    // --- FUNGSI MENGISI KEMBALI FORM ---
    function populateForm(config) {
      if (!config) return;
      if(config.govText) document.getElementById('inpGov').value = config.govText;
      if(config.dinasText) document.getElementById('inpDinas').value = config.dinasText;
      if(config.schoolText) document.getElementById('inpSchool').value = config.schoolText;
      if(config.addressText) document.getElementById('inpAddress').value = config.addressText;
      if(config.logoLeftUrl) document.getElementById('inpLogoLeft').value = config.logoLeftUrl;
      if(config.logoRightUrl) document.getElementById('inpLogoRight').value = config.logoRightUrl;
      if(config.ttdTanggal) document.getElementById('inpTtdTanggal').value = config.ttdTanggal;
      if(config.kepsek) document.getElementById('inpKepsek').value = config.kepsek;
      if(config.nip) document.getElementById('inpNip').value = config.nip;
      if(config.namaUjian) document.getElementById('inpNamaUjian').value = config.namaUjian;
      if(config.tahun) document.getElementById('inpTahun').value = config.tahun;
      if(config.prefixUjian) document.getElementById('inpPrefixUjian').value = config.prefixUjian;
      if(config.semester) document.getElementById('inpSemester').value = config.semester;
      
      // Kosongkan textarea agar siap menerima paste data baru
      document.getElementById('inpStudents').value = "";
    }

    // --- MENGISI DROPDOWN KELAS ---
    function populateClassFilter(students) {
      const classes = [...new Set(students.map(s => s.kelas))].filter(Boolean); 
      const select = document.getElementById('filterKelasCetak');
      select.innerHTML = '<option value="ALL">Semua Kelas</option>';
      
      classes.forEach(c => {
        const opt = document.createElement('option');
        opt.value = c;
        opt.textContent = "Kelas " + c;
        select.appendChild(opt);
      });
    }

    // --- FILTER & RENDER KARTU ---
    function renderFilteredCards() {
      const selectedClass = document.getElementById('filterKelasCetak').value;
      let filteredStudents = GLOBAL_STUDENTS;
      
      if (selectedClass !== "ALL") {
        filteredStudents = GLOBAL_STUDENTS.filter(s => s.kelas === selectedClass);
      }
      
      buildCardsHtml(GLOBAL_CONFIG, filteredStudents);
    }

    // --- AMBIL DATA KONFIGURASI ---
    function getConfigData() {
        return {
            govText: document.getElementById('inpGov').value,
            dinasText: document.getElementById('inpDinas').value,
            schoolText: document.getElementById('inpSchool').value,
            addressText: document.getElementById('inpAddress').value,
            logoLeftUrl: document.getElementById('inpLogoLeft').value,
            logoRightUrl: document.getElementById('inpLogoRight').value,
            ttdTanggal: document.getElementById('inpTtdTanggal').value,
            kepsek: document.getElementById('inpKepsek').value,
            nip: document.getElementById('inpNip').value,
            namaUjian: document.getElementById('inpNamaUjian').value, 
            tahun: document.getElementById('inpTahun').value,
            prefixUjian: document.getElementById('inpPrefixUjian').value, 
            semester: document.getElementById('inpSemester').value
        };
    }

    // --- FUNGSI 1: SIMPAN KONFIGURASI SAJA ---
    function saveConfigOnly() {
      const configData = getConfigData();
      showLoading("Menyimpan Konfigurasi...");
      
      GLOBAL_CONFIG = configData;
      localStorage.setItem('kartuUjianConfig_v3', JSON.stringify(GLOBAL_CONFIG));

      if (typeof google !== 'undefined' && google.script && google.script.run) {
        google.script.run
          .withSuccessHandler(function() {
            hideLoading();
            showSuccess("Konfigurasi Tersimpan!");
          })
          .withFailureHandler(function() {
            hideLoading();
            showError("Gagal menyimpan ke Server.");
          })
          .saveToDatabase(configData, [], 'append'); 
      } else {
        setTimeout(() => {
          hideLoading();
          showSuccess("Konfigurasi Tersimpan (Lokal)!");
        }, 500);
      }
    }

    // --- FUNGSI 2: SIMPAN DATA SISWA (PARSING EXCEL PASTE) ---
    function saveStudents(mode) {
      if (mode === 'overwrite' && !confirm("Peringatan Keras! Seluruh data siswa yang ada di sistem akan dihapus permanen dan diganti dari awal. Lanjutkan?")) {
          return;
      }

      const configData = getConfigData();
      const rawStudents = document.getElementById('inpStudents').value;
      const studentsArray = [];
      
      rawStudents.split('\n').forEach(line => {
        if(line.trim() === '') return;
        let parts = line.split('\t'); 
        if(parts.length < 2) parts = line.split(','); 

        studentsArray.push({
          nama: parts[0] ? parts[0].trim() : "NAMA KOSONG",
          kelas: parts[1] ? parts[1].trim() : "-",
          ruang: parts[2] ? parts[2].trim() : "-"
        });
      });

      if(studentsArray.length === 0) {
        showError("Data siswa belum di paste!");
        return;
      }

      showLoading("Menyimpan Data Siswa...");

      if (typeof google !== 'undefined' && google.script && google.script.run) {
        google.script.run
          .withSuccessHandler(function() {
            forceSyncFromDatabase(true); 
          })
          .withFailureHandler(function() {
            hideLoading();
            showError("Gagal terhubung ke DB.");
          })
          .saveToDatabase(configData, studentsArray, mode);
      } else {
        // FALLBACK LOKAL
        setTimeout(() => {
          hideLoading();
          if (mode === 'append') {
            GLOBAL_STUDENTS = GLOBAL_STUDENTS.concat(studentsArray);
          } else {
            GLOBAL_STUDENTS = studentsArray;
          }
          GLOBAL_CONFIG = configData;
          localStorage.setItem('kartuUjianConfig_v3', JSON.stringify(GLOBAL_CONFIG));
          localStorage.setItem('kartuUjianStudents_v3', JSON.stringify(GLOBAL_STUDENTS));
          
          document.getElementById('inpStudents').value = ""; 
          populateClassFilter(GLOBAL_STUDENTS);
          showSuccess("Tersimpan secara Lokal!");
          switchTab('cetak');
        }, 500);
      }
    }

    // --- FUNGSI 3: TARIK PAKSA DATA DARI DB SERVER ---
    function forceSyncFromDatabase(autoSwitchTab = false) {
      if (typeof google === 'undefined' || !google.script || !google.script.run) {
        return;
      }
      
      showLoading("Menyinkronkan dari Server...");
      google.script.run
        .withSuccessHandler(function(result) {
          hideLoading();
          if(result.config && Object.keys(result.config).length > 0) {
             showSuccess("Data tersinkronisasi!");
             GLOBAL_CONFIG = result.config;
             GLOBAL_STUDENTS = result.students;
             localStorage.setItem('kartuUjianConfig_v3', JSON.stringify(result.config));
             localStorage.setItem('kartuUjianStudents_v3', JSON.stringify(result.students));
             
             document.getElementById('inpStudents').value = ""; 
             populateForm(GLOBAL_CONFIG); 
             populateClassFilter(GLOBAL_STUDENTS);
             renderFilteredCards();
             if (autoSwitchTab) switchTab('cetak');
          }
        })
        .withFailureHandler(function() {
          hideLoading();
          showError("Gagal sinkronisasi jaringan.");
        })
        .loadFromDatabase();
    }

    // --- HELPER RENDERING ---
    const SVG_TUTWURI = `<svg viewBox="0 0 100 100" class="logo-img"><path d="M50 20 L80 40 L80 80 L20 80 L20 40 Z" fill="none" stroke="#000" stroke-width="3"/><circle cx="50" cy="50" r="12" fill="#000"/></svg>`;
    const SVG_SCHOOL = `<svg viewBox="0 0 100 100" class="logo-img"><rect x="15" y="15" width="70" height="70" fill="none" stroke="#000" stroke-width="3"/><text x="50" y="65" font-size="45" font-weight="bold" fill="#000" text-anchor="middle">S</text></svg>`;

    function getLogoHtml(url, defaultSvg) {
      const strUrl = url ? url.toString().trim() : ''; 
      if (strUrl !== '') {
        const safeSvgUri = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(defaultSvg);
        return `<img src="${strUrl}" class="logo-img" onerror="this.src='${safeSvgUri}'">`;
      }
      return defaultSvg;
    }

    function getWatermarkHtml(url, defaultSvg) {
      const strUrl = url ? url.toString().trim() : ''; 
      const defaultWatermarkSvg = defaultSvg.replace('<svg ', '<svg class="watermark-img" ');
      if (strUrl !== '') {
        const safeSvgUri = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(defaultWatermarkSvg);
        return `<img src="${strUrl}" class="watermark-img" onerror="this.src='${safeSvgUri}'">`;
      }
      return defaultWatermarkSvg;
    }

    function pad(num, size) {
      let s = "000" + num;
      return s.slice(-size);
    }

    // --- FUNGSI 4: BUILD HTML KARTU ---
    function buildCardsHtml(config, students) {
      const container = document.getElementById('cardContainer');
      container.innerHTML = '';

      if(!students || students.length === 0) {
        container.innerHTML = '<div class="text-center p-5 text-danger fw-bold w-100" style="grid-column: 1 / -1;">Tidak ada data siswa untuk kelas ini.</div>';
        return;
      }

      const ttdTanggal = config.ttdTanggal || 'Lempur, 19 Februari 2026';
      const tahunPelajaran = config.tahun || '2025/2026';
      const logoKiri = getLogoHtml(config.logoLeftUrl, SVG_TUTWURI);
      const logoKanan = getLogoHtml(config.logoRightUrl, SVG_SCHOOL);
      const logoWatermark = getWatermarkHtml(config.logoRightUrl, SVG_SCHOOL);
      
      const namaUjianTeks = config.namaUjian ? config.namaUjian.toUpperCase() : "SUMATIF AKHIR SEMESTER";
      const semesterTeks = config.semester ? config.semester.toUpperCase() : "GANJIL";
      const prefixUjian = config.prefixUjian ? config.prefixUjian : "25-03";
      
      const qrCodesToRender = [];
      let html = '';

      students.forEach((siswa, idx) => {
        const noUrut = idx + 1;
        const kelasStr = (siswa.kelas || "0").toString();
        const prefixKelas = kelasStr.replace(/[^0-9]/g, '') || '0'; 
        
        const noPeserta = `${prefixUjian}-${pad(prefixKelas, 2)}-${pad(noUrut, 3)}-8`;

        html += `
        <div class="exam-card">
          <div class="watermark-container">
            ${logoWatermark}
          </div>
          
          <div class="card-header-area">
            <div class="logo-box">${logoKiri}</div>
            <div class="header-text">
              <div class="gov-text">${config.govText || ''}</div>
              <div class="dinas-text">${config.dinasText || 'DINAS PENDIDIKAN'}</div>
              <div class="school-text">${config.schoolText || ''}</div>
              <p>${config.addressText || ''}</p>
            </div>
            <div class="logo-box">${logoKanan}</div>
          </div>
          <div class="header-divider"></div>

          <div class="card-title-box">
            <div class="card-title-text">KARTU PESERTA ${namaUjianTeks} ${semesterTeks}</div>
            <div class="card-subtitle-text" style="margin-top:2px;">TAHUN PELAJARAN ${tahunPelajaran}</div>
          </div>

          <div class="card-body-area">
            <div class="data-row">
              <div class="data-label">No. Peserta</div>
              <div class="data-separator">:</div>
              <div class="data-value">${noPeserta}</div>
            </div>
            <div class="data-row">
              <div class="data-label">Nama Siswa</div>
              <div class="data-separator">:</div>
              <div class="data-value text-uppercase">${siswa.nama}</div>
            </div>
            <div class="data-row">
              <div class="data-label">Kelas/Ruang</div>
              <div class="data-separator">:</div>
              <div class="data-value">${siswa.kelas} / ${siswa.ruang}</div>
            </div>
          </div>

          <!-- FOOTER -->
          <div class="card-footer-area">
            
            <div class="footer-left">
              <div class="qrcode-box" id="qrcode-${idx}"></div>
              <div class="photo-box">FOTO<br>2x3</div>
            </div>
            
            <!-- Posisi Flexbox Tanda Tangan: Sejajar sempurna atas dan bawah kotak foto -->
            <div class="signature-box">
              <div class="sig-top">
                <div>${ttdTanggal}</div>
                <div>Kepala Sekolah,</div>
              </div>
              <div class="sig-bottom">
                <div class="kepsek-name">${config.kepsek || '...'}</div>
                <div>NIP. ${config.nip || '...'}</div>
              </div>
            </div>
            
          </div>
        </div>
        `;

        qrCodesToRender.push({ id: `qrcode-${idx}`, text: noPeserta });
      });

      container.innerHTML = html;

      // Merender QR Code dengan bersih dan sinkron dengan UI Thread
      setTimeout(() => {
        qrCodesToRender.forEach(qr => {
          try {
            new QRCode(document.getElementById(qr.id), {
              text: qr.text,
              width: 55, 
              height: 55,
              colorDark : "#000000",
              colorLight : "#ffffff",
              correctLevel : QRCode.CorrectLevel.L
            });
          } catch (e) { console.error("QR Error", e); }
        });
      }, 100); // 100ms agar tab Cetak termuat sepenuhnya di browser
    }
  </script>
</body>
</html>

Untuk Database Spreadsheet akan terbuat otomatis di drive dengan nama "Database_KartuUjian_Webapp" jangan lupa atur ke publik (lihat).