Selasa, 16 Desember 2025

Apps Script - Dashboard Pelatihan

 








Code.gs

/** =========================
 * CONFIG
 * ========================= */
const SPREADSHEET_ID = "1ssF1Tb8yuTx1_wStpM0T9t8QfdkbUb99F1Rs80m_MIA"; // boleh kosong jika bound
const SHEET_NAME = "DataPelatihan";

// Struktur kolom (header)
const HEADERS = ["ID", "Timestamp", "Nama", "Email", "Instansi", "Kelas", "Catatan"];

/** =========================
 * Web App Entry
 * ========================= */
function doGet(e) {
  return HtmlService
    .createHtmlOutputFromFile("index")
    .setTitle("Form Pelatihan / Workshop")
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

/** =========================
 * CREATE
 * payload: {nama,email,instansi,kelas,catatan}
 * ========================= */
function createTraining(payload) {
  payload = payload || {};
  const data = normalizePayload_(payload, true);

  const ss = getSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, SHEET_NAME);
  ensureHeader_(sheet);

  const id = Utilities.getUuid();
  const ts = new Date();

  sheet.appendRow([
    id,
    ts,
    data.nama,
    data.email,
    data.instansi,
    data.kelas,
    data.catatan
  ]);

  return { ok: true, message: "Berhasil ditambahkan.", id };
}

/** =========================
 * READ (list)
 * return array of objects
 * ========================= */
function listTrainings() {
  const sheet = getOrCreateSheet_(getSpreadsheet_(), SHEET_NAME);
  ensureHeader_(sheet);

  const values = sheet.getDataRange().getValues();
  if (values.length <= 1) return { ok: true, data: [] };

  const header = values[0];
  const idx = indexMap_(header);

  const out = [];
  for (let i = 1; i < values.length; i++) {
    const row = values[i];
    const id = String(row[idx.ID] || "").trim();
    if (!id) continue;

    out.push({
      id,
      timestamp: row[idx.Timestamp] ? new Date(row[idx.Timestamp]).toISOString() : "",
      nama: row[idx.Nama] || "",
      email: row[idx.Email] || "",
      instansi: row[idx.Instansi] || "",
      kelas: row[idx.Kelas] || "",
      catatan: row[idx.Catatan] || ""
    });
  }

  // terbaru dulu
  out.sort((a,b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
  return { ok: true, data: out };
}

/** =========================
 * READ (single)
 * ========================= */
function getTrainingById(id) {
  if (!id) throw new Error("ID kosong.");
  const found = findRowById_(String(id));
  if (!found) throw new Error("Data tidak ditemukan.");

  return { ok: true, data: found.obj };
}

/** =========================
 * UPDATE
 * payload: {id,nama,email,instansi,kelas,catatan}
 * ========================= */
function updateTraining(payload) {
  payload = payload || {};
  const id = String(payload.id || "").trim();
  if (!id) throw new Error("ID wajib.");

  const data = normalizePayload_(payload, true);

  const ss = getSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, SHEET_NAME);
  ensureHeader_(sheet);

  const values = sheet.getDataRange().getValues();
  const header = values[0];
  const idx = indexMap_(header);

  for (let r = 1; r < values.length; r++) {
    const rowId = String(values[r][idx.ID] || "").trim();
    if (rowId === id) {
      // update kolom (timestamp tidak diubah; kalau mau, bisa tambah UpdatedAt)
      sheet.getRange(r + 1, idx.Nama + 1).setValue(data.nama);
      sheet.getRange(r + 1, idx.Email + 1).setValue(data.email);
      sheet.getRange(r + 1, idx.Instansi + 1).setValue(data.instansi);
      sheet.getRange(r + 1, idx.Kelas + 1).setValue(data.kelas);
      sheet.getRange(r + 1, idx.Catatan + 1).setValue(data.catatan);

      return { ok: true, message: "Berhasil diupdate.", id };
    }
  }

  throw new Error("ID tidak ditemukan.");
}

/** =========================
 * DELETE
 * ========================= */
function deleteTraining(id) {
  id = String(id || "").trim();
  if (!id) throw new Error("ID wajib.");

  const ss = getSpreadsheet_();
  const sheet = getOrCreateSheet_(ss, SHEET_NAME);
  ensureHeader_(sheet);

  const values = sheet.getDataRange().getValues();
  const header = values[0];
  const idx = indexMap_(header);

  for (let r = 1; r < values.length; r++) {
    const rowId = String(values[r][idx.ID] || "").trim();
    if (rowId === id) {
      sheet.deleteRow(r + 1);
      return { ok: true, message: "Berhasil dihapus.", id };
    }
  }
  throw new Error("ID tidak ditemukan.");
}

/** =========================
 * Helpers
 * ========================= */
function getSpreadsheet_() {
  if (SPREADSHEET_ID && SPREADSHEET_ID !== "PASTE_SPREADSHEET_ID_DI_SINI") {
    return SpreadsheetApp.openById(SPREADSHEET_ID);
  }
  return SpreadsheetApp.getActiveSpreadsheet();
}

function getOrCreateSheet_(ss, name) {
  let sh = ss.getSheetByName(name);
  if (!sh) sh = ss.insertSheet(name);
  return sh;
}

function ensureHeader_(sheet) {
  const lastCol = Math.max(sheet.getLastColumn(), HEADERS.length);
  const first = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
  const isEmpty = first.join("").trim() === "";

  if (isEmpty) {
    sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
    sheet.setFrozenRows(1);
    sheet.autoResizeColumns(1, HEADERS.length);
    return;
  }

  // kalau header belum sesuai, minimal pastikan kolom HEADERS ada di depan
  const current = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
  const need = HEADERS.some(h => !current.includes(h));
  if (need) {
    // rebuild header di baris 1 (aman untuk kasus awal)
    sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
    sheet.setFrozenRows(1);
  }
}

function indexMap_(headerRow) {
  const map = {};
  headerRow.forEach((h, i) => map[String(h)] = i);

  // fallback kalau posisi berubah
  return {
    ID: map.ID ?? 0,
    Timestamp: map.Timestamp ?? 1,
    Nama: map.Nama ?? 2,
    Email: map.Email ?? 3,
    Instansi: map.Instansi ?? 4,
    Kelas: map.Kelas ?? 5,
    Catatan: map.Catatan ?? 6
  };
}

function normalizePayload_(p, requireCore) {
  const nama = String(p.nama || "").trim();
  const email = String(p.email || "").trim();
  const kelas = String(p.kelas || "").trim();

  if (requireCore) {
    if (!nama) throw new Error("Nama wajib diisi.");
    if (!email) throw new Error("Email wajib diisi.");
    if (!kelas) throw new Error("Kelas wajib diisi.");
  }

  return {
    nama,
    email,
    instansi: String(p.instansi || "").trim(),
    kelas,
    catatan: String(p.catatan || "").trim()
  };
}

function findRowById_(id) {
  const sheet = getOrCreateSheet_(getSpreadsheet_(), SHEET_NAME);
  ensureHeader_(sheet);
  const values = sheet.getDataRange().getValues();
  if (values.length <= 1) return null;

  const header = values[0];
  const idx = indexMap_(header);

  for (let r = 1; r < values.length; r++) {
    const row = values[r];
    const rowId = String(row[idx.ID] || "").trim();
    if (rowId === id) {
      return {
        rowNumber: r + 1,
        obj: {
          id: rowId,
          timestamp: row[idx.Timestamp] ? new Date(row[idx.Timestamp]).toISOString() : "",
          nama: row[idx.Nama] || "",
          email: row[idx.Email] || "",
          instansi: row[idx.Instansi] || "",
          kelas: row[idx.Kelas] || "",
          catatan: row[idx.Catatan] || ""
        }
      };
    }
  }
  return null;
}


Index.html

<!doctype html>
<html lang="id">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Pelatihan / Workshop — Form Publik</title>

  <style>
    :root{
      --bg0:#071a3a; --bg1:#0b2a6b;
      --ink:#0b1220; --white:#ffffff;
      --card:rgba(255,255,255,.92);
      --shadow: 0 22px 55px rgba(0,0,0,.25);
      --shadow-soft: 0 10px 30px rgba(2, 10, 35, .18);
      --radius: 20px; --max: 1120px;
      --focus: 0 0 0 3px rgba(14,165,233,.25);
      --btn: linear-gradient(135deg, #38bdf8, #0ea5e9, #2563eb);
    }
    *{ box-sizing:border-box; }
    html,body{ height:100%; }
    body{
      margin:0;
      font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
      color: var(--white);
      background:
        radial-gradient(1200px 700px at 20% 10%, rgba(56,189,248,.45), transparent 60%),
        radial-gradient(900px 600px at 80% 30%, rgba(37,99,235,.45), transparent 62%),
        linear-gradient(135deg, var(--bg0), var(--bg1) 45%, #06214a);
      overflow-x:hidden;
    }
    a{ color:inherit; text-decoration:none; }
    button{ font:inherit; }
    code{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }

    .container{ width: min(var(--max), calc(100% - 48px)); margin: 0 auto; }

    header{
      position: sticky; top: 0; z-index: 10;
      backdrop-filter: blur(12px);
      background: rgba(6, 19, 54, .55);
      border-bottom: 1px solid rgba(255,255,255,.10);
    }
    .topbar{ display:flex; align-items:center; justify-content:space-between; padding:14px 0; gap:14px; }
    .brand{ display:flex; align-items:center; gap:10px; font-weight:800; letter-spacing:.2px; white-space:nowrap; }
    .logo{
      width:38px;height:38px;border-radius:12px;
      background: linear-gradient(135deg, rgba(56,189,248,.95), rgba(37,99,235,.95));
      box-shadow: 0 12px 25px rgba(14,165,233,.25);
      display:grid;place-items:center;
      border:1px solid rgba(255,255,255,.18);
    }
    nav{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
    .pill{
      padding:10px 14px;border-radius:999px;
      border:1px solid rgba(255,255,255,.14);
      background: rgba(255,255,255,.06);
      cursor:pointer; user-select:none;
      transition: transform .12s ease, background .12s ease;
    }
    .pill:hover{ transform: translateY(-1px); background: rgba(255,255,255,.09); }
    .pill[aria-current="page"]{ background: rgba(56,189,248,.16); border-color: rgba(56,189,248,.38); }
    .cta-mini{
      padding:10px 14px;border-radius:999px;
      background: rgba(255,255,255,.92);
      color:#0b2a6b;border:1px solid rgba(255,255,255,.35);
      cursor:pointer; font-weight:900;
      transition: transform .12s ease;
    }
    .cta-mini:hover{ transform: translateY(-1px); }

    main{ min-height: calc(100dvh - 70px); }
    .view{ display:none; }
    .view.is-active{ display:block; }

    /* Hero */
    section.hero{ padding: 52px 0 32px; }
    .hero-grid{ display:grid; grid-template-columns: 1.05fr .95fr; gap:34px; align-items:center; padding:22px 0 10px; }
    .kicker{
      display:inline-flex; align-items:center; gap:10px;
      padding:8px 12px;border-radius:999px;
      border:1px solid rgba(255,255,255,.18);
      background: rgba(255,255,255,.08);
      font-size:13px;color: rgba(255,255,255,.92);
    }
    .dot{ width:8px;height:8px;border-radius:999px;background:#38bdf8; box-shadow:0 0 0 4px rgba(56,189,248,.18); }
    h1{ margin:16px 0 12px; font-size: clamp(32px, 4vw, 52px); line-height:1.06; letter-spacing:-.6px; }
    .lead{ margin:0 0 18px; font-size:16px; line-height:1.7; color: rgba(255,255,255,.86); max-width:60ch; }
    .hero-actions{ display:flex; gap:12px; flex-wrap:wrap; margin-top:10px; }
    .btn{
      border:0; padding:14px 16px; border-radius:14px;
      cursor:pointer; font-weight:900; letter-spacing:.2px;
      display:inline-flex; align-items:center; gap:10px;
      transition: transform .12s ease, box-shadow .12s ease;
    }
    .btn-primary{ color:var(--white); background:var(--btn); box-shadow:0 16px 28px rgba(14,165,233,.25); border:1px solid rgba(255,255,255,.18); }
    .btn-primary:hover{ transform: translateY(-1px); box-shadow:0 18px 32px rgba(14,165,233,.30); }
    .btn-ghost{ color:rgba(255,255,255,.92); background: rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18); }
    .btn-ghost:hover{ transform: translateY(-1px); }
    .meta{ margin-top:18px; display:flex; gap:18px; flex-wrap:wrap; color: rgba(255,255,255,.82); font-size:13px; }
    .meta b{ color:#fff; }

    .illus-card{
      border-radius: var(--radius);
      background: linear-gradient(180deg, rgba(255,255,255,.10), rgba(255,255,255,.06));
      border: 1px solid rgba(255,255,255,.16);
      box-shadow: var(--shadow-soft);
      overflow:hidden; min-height: 380px;
    }
    .illus-top{
      padding:16px 18px; display:flex; align-items:center; justify-content:space-between;
      border-bottom:1px solid rgba(255,255,255,.12);
      background: rgba(0,0,0,.08);
    }
    .window-dots{ display:flex; gap:8px; }
    .window-dots span{ width:10px;height:10px;border-radius:999px;background: rgba(255,255,255,.35); }
    .illus-body{ padding:18px; display:grid; place-items:center; height: calc(100% - 54px); }

    /* Features */
    .features{ margin-top:18px; display:grid; grid-template-columns: repeat(3,1fr); gap:14px; padding-bottom:14px; }
    .feat{ border-radius:18px; padding:14px; border:1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.06); box-shadow:0 12px 22px rgba(2,10,35,.12); }
    .feat h3{ margin:8px 0 6px; font-size:14px; }
    .feat p{ margin:0; font-size:13px; line-height:1.55; color: rgba(255,255,255,.82); }

    /* Panels + Form */
    section.block{ padding: 42px 0 64px; }
    .block-header{ display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:16px; }
    .block-header h2{ margin:0; font-size: clamp(22px, 2.6vw, 30px); letter-spacing:-.3px; }
    .block-header p{ margin:6px 0 0; color: rgba(255,255,255,.82); line-height:1.6; font-size:14px; max-width:75ch; }

    .split{ display:grid; grid-template-columns: .9fr 1.1fr; gap:16px; align-items:start; }
    .panel{ border-radius: var(--radius); background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.14); box-shadow: var(--shadow-soft); overflow:hidden; }
    .panel .head{ padding:14px 16px; border-bottom:1px solid rgba(255,255,255,.12); background: rgba(0,0,0,.08); display:flex; align-items:center; justify-content:space-between; gap:10px; }
    .badge{ font-size:12px; padding:6px 10px; border-radius:999px; border:1px solid rgba(56,189,248,.30); background: rgba(56,189,248,.12); color: rgba(255,255,255,.92); white-space:nowrap; }
    .panel .body{ padding:16px; }

    form{
      background: var(--card);
      color: var(--ink);
      border-radius: var(--radius);
      padding: 18px;
      box-shadow: var(--shadow);
      border: 1px solid rgba(255,255,255,.35);
    }
    .grid{ display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
    .field{ display:flex; flex-direction:column; gap:8px; margin-bottom:12px; }
    label{ font-size:13px; color:#111827; font-weight:900; }
    input, select, textarea{
      border: 1px solid rgba(15, 23, 42, .18);
      border-radius: 12px;
      padding: 12px 12px;
      outline:none;
      font-size:14px;
      background: rgba(255,255,255,.92);
      transition: box-shadow .12s ease, border-color .12s ease;
    }
    input:focus, select:focus, textarea:focus{ box-shadow: var(--focus); border-color: rgba(14,165,233,.65); }
    textarea{ resize: vertical; min-height: 92px; }

    .form-actions{ display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:10px; }
    .hint{ color: rgba(17,24,39,.70); font-size:12px; line-height:1.5; max-width:62ch; }

    .btn-submit{
      background: linear-gradient(135deg, #0ea5e9, #2563eb);
      color: white;
      border: 0;
      padding: 12px 14px;
      border-radius: 12px;
      cursor:pointer;
      font-weight: 900;
      box-shadow: 0 14px 22px rgba(37,99,235,.22);
      transition: transform .12s ease, opacity .12s ease;
      display:inline-flex; align-items:center; gap:10px;
    }
    .btn-submit:hover{ transform: translateY(-1px); }
    .btn-submit:disabled{ opacity:.65; cursor:not-allowed; transform:none; }

    .spinner{
      width: 14px; height: 14px;
      border-radius: 999px;
      border: 2px solid rgba(255,255,255,.55);
      border-top-color: rgba(255,255,255,1);
      animation: spin .8s linear infinite;
      display:none;
    }
    .btn-submit.is-loading .spinner{ display:inline-block; }
    @keyframes spin{ to{ transform: rotate(360deg); } }

    .status{
      margin-top: 12px;
      padding: 12px 12px;
      border-radius: 14px;
      border: 1px dashed rgba(255,255,255,.22);
      background: rgba(255,255,255,.06);
      color: rgba(255,255,255,.92);
      display:none;
      line-height: 1.55;
      word-break: break-word;
    }
    .status.is-show{ display:block; }
    .status.ok{ border-color: rgba(34,197,94,.35); background: rgba(34,197,94,.12); }
    .status.err{ border-color: rgba(239,68,68,.40); background: rgba(239,68,68,.12); }

    /* Data table */
    .table-wrap{
      background: rgba(255,255,255,.06);
      border:1px solid rgba(255,255,255,.14);
      box-shadow: var(--shadow-soft);
      border-radius: var(--radius);
      overflow:hidden;
    }
    .table-top{
      padding: 14px 16px;
      display:flex; gap:10px; align-items:center; justify-content:space-between; flex-wrap:wrap;
      border-bottom:1px solid rgba(255,255,255,.12);
      background: rgba(0,0,0,.08);
    }
    .search{
      display:flex; gap:10px; align-items:center; flex-wrap:wrap;
    }
    .search input{
      width: min(360px, 70vw);
      background: rgba(255,255,255,.92);
    }
    table{
      width:100%;
      border-collapse: collapse;
      color: rgba(255,255,255,.92);
      font-size: 13px;
    }
    th, td{
      padding: 12px 12px;
      border-bottom: 1px solid rgba(255,255,255,.10);
      vertical-align: top;
    }
    th{ text-align:left; font-weight:900; color: rgba(255,255,255,.92); }
    td{ color: rgba(255,255,255,.88); }
    .actions{ display:flex; gap:8px; flex-wrap:wrap; }
    .btn-sm{
      padding: 8px 10px;
      border-radius: 12px;
      border: 1px solid rgba(255,255,255,.16);
      background: rgba(255,255,255,.08);
      color: rgba(255,255,255,.95);
      cursor:pointer;
      font-weight:900;
    }
    .btn-danger{
      border-color: rgba(239,68,68,.35);
      background: rgba(239,68,68,.14);
    }

    /* Modal */
    .modal{
      position: fixed; inset: 0;
      background: rgba(0,0,0,.55);
      display:none;
      align-items:center; justify-content:center;
      padding: 22px;
      z-index: 50;
    }
    .modal.is-open{ display:flex; }
    .modal-card{
      width: min(760px, 100%);
      border-radius: var(--radius);
      border: 1px solid rgba(255,255,255,.18);
      background: rgba(8, 27, 70, .92);
      backdrop-filter: blur(10px);
      box-shadow: var(--shadow);
      overflow:hidden;
    }
    .modal-head{
      padding: 14px 16px;
      display:flex; align-items:center; justify-content:space-between; gap:12px;
      border-bottom: 1px solid rgba(255,255,255,.12);
      background: rgba(0,0,0,.10);
    }
    .modal-body{ padding: 16px; }

    footer{ padding: 18px 0 30px; color: rgba(255,255,255,.75); font-size: 13px; }

    @media (max-width: 980px){
      .hero-grid{ grid-template-columns: 1fr; }
      .illus-card{ min-height: 340px; }
      .features{ grid-template-columns: 1fr; }
      .split{ grid-template-columns: 1fr; }
      .grid{ grid-template-columns: 1fr; }
      th:nth-child(2), td:nth-child(2){ display:none; } /* hide timestamp on small */
    }
  </style>
</head>

<body>
  <header>
    <div class="container">
      <div class="topbar">
        <div class="brand" role="banner" aria-label="Brand Pelatihan">
          <div class="logo" aria-hidden="true">
            <svg viewBox="0 0 24 24" fill="none" width="20" height="20">
              <path d="M7 17V9.8c0-.5.3-1 .8-1.2l3.5-1.6c.4-.2.9-.2 1.3 0l3.5 1.6c.5.2.8.7.8 1.2V17" stroke="white" stroke-width="2" stroke-linecap="round"/>
              <path d="M6 17h12" stroke="white" stroke-width="2" stroke-linecap="round"/>
            </svg>
          </div>
          <span>Pelatihan / Workshop</span>
        </div>

        <nav aria-label="Navigasi">
          <button class="pill" data-route="landing" aria-current="page" type="button">Landing</button>
          <button class="pill" data-route="form" type="button">Form Input</button>
          <button class="pill" data-route="data" type="button">Data Peserta</button>
          <button class="cta-mini" data-route="form" type="button">Mulai Isi Data</button>
        </nav>
      </div>
    </div>
  </header>

  <main>
    <!-- VIEW: Landing -->
    <div id="view-landing" class="view is-active">
      <section class="hero">
        <div class="container">
          <div class="hero-grid">
            <div>
              <span class="kicker"><span class="dot" aria-hidden="true"></span> CRUD Lengkap • Data tersimpan di Google Sheet</span>
              <h1>Form Pelatihan Modern dengan Dashboard Data Peserta</h1>
              <p class="lead">
                Landing page clean, SPA-style sederhana tanpa reload, form terpisah secara visual,
                dan halaman “Data Peserta” untuk edit & hapus data langsung dari Google Sheet.
              </p>

              <div class="hero-actions">
                <button class="btn btn-primary" data-route="form" type="button">
                  Mulai Isi Data
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                    <path d="M5 12h12" stroke="white" stroke-width="2" stroke-linecap="round"/>
                    <path d="M13 6l6 6-6 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
                  </svg>
                </button>
                <button class="btn btn-ghost" data-route="data" type="button">Lihat Data Peserta</button>
              </div>

              <div class="meta">
                <span><b>Create</b> via form</span>
                <span><b>Edit</b> via modal</span>
                <span><b>Delete</b> by ID</span>
              </div>
            </div>

            <div class="illus-card">
              <div class="illus-top">
                <div class="window-dots" aria-hidden="true"><span></span><span></span><span></span></div>
                <span style="font-size:13px;color:rgba(255,255,255,.85);">Sistem Informasi Pelatihan</span>
              </div>
              <div class="illus-body">
                <!-- Ilustrasi SVG -->
                <svg viewBox="0 0 720 420" width="100%" height="100%" role="img" aria-label="Ilustrasi sistem pelatihan">
                  <defs>
                    <linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
                      <stop offset="0" stop-color="#38bdf8" stop-opacity=".95"/>
                      <stop offset="1" stop-color="#2563eb" stop-opacity=".95"/>
                    </linearGradient>
                    <linearGradient id="g2" x1="0" y1="1" x2="1" y2="0">
                      <stop offset="0" stop-color="#0ea5e9" stop-opacity=".35"/>
                      <stop offset="1" stop-color="#93c5fd" stop-opacity=".15"/>
                    </linearGradient>
                  </defs>
                  <path d="M90 320c110 110 300 120 390 40 120-105 65-240-80-265-150-25-250 65-310 225z" fill="url(#g2)"/>
                  <circle cx="590" cy="110" r="70" fill="url(#g2)"/>
                  <rect x="110" y="70" rx="22" ry="22" width="500" height="280" fill="rgba(255,255,255,.10)" stroke="rgba(255,255,255,.18)"/>
                  <rect x="140" y="105" rx="16" ry="16" width="200" height="210" fill="rgba(255,255,255,.08)" stroke="rgba(255,255,255,.16)"/>
                  <rect x="360" y="105" rx="16" ry="16" width="220" height="120" fill="rgba(255,255,255,.08)" stroke="rgba(255,255,255,.16)"/>
                  <rect x="360" y="245" rx="16" ry="16" width="220" height="70" fill="rgba(255,255,255,.08)" stroke="rgba(255,255,255,.16)"/>
                  <path d="M388 198 L420 166 L452 184 L484 140 L516 160 L548 132" fill="none" stroke="url(#g1)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
                  <g>
                    <rect x="160" y="130" width="160" height="16" rx="8" fill="rgba(255,255,255,.22)"/>
                    <rect x="160" y="160" width="130" height="14" rx="7" fill="rgba(255,255,255,.18)"/>
                    <rect x="160" y="190" width="150" height="14" rx="7" fill="rgba(255,255,255,.18)"/>
                    <rect x="160" y="220" width="120" height="14" rx="7" fill="rgba(255,255,255,.18)"/>
                    <rect x="160" y="250" width="150" height="14" rx="7" fill="rgba(255,255,255,.18)"/>
                  </g>
                  <g>
                    <rect x="390" y="262" width="170" height="46" rx="14" fill="url(#g1)"/>
                    <text x="475" y="291" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="14" font-weight="900" fill="white">
                      CRUD
                    </text>
                  </g>
                </svg>
              </div>
            </div>
          </div>

          <div class="features">
            <div class="feat">
              <h3>Semantic + rapi</h3>
              <p>HTML semantic + CSS terstruktur untuk tampilan profesional.</p>
            </div>
            <div class="feat">
              <h3>SPA-style</h3>
              <p>Routing hash: Landing → Form → Data tanpa reload.</p>
            </div>
            <div class="feat">
              <h3>CRUD Sheet</h3>
              <p>Create, List, Update, Delete langsung ke Google Sheet.</p>
            </div>
          </div>
        </div>
      </section>

      <footer><div class="container">© <span id="year"></span> Pelatihan / Workshop — GAS Web App</div></footer>
    </div>

    <!-- VIEW: Form (Create) -->
    <div id="view-form" class="view">
      <section class="block">
        <div class="container">
          <div class="block-header">
            <div>
              <h2>Form Pendaftaran</h2>
              <p>Isi data berikut. Setelah submit, data otomatis masuk ke Google Sheet (tab <b>DataPelatihan</b>).</p>
            </div>
            <button class="pill" data-route="landing" type="button">← Kembali</button>
          </div>

          <div class="split">
            <div class="panel">
              <div class="head">
                <strong>Informasi</strong>
                <span class="badge">Create</span>
              </div>
              <div class="body">
                <ul style="margin:0; padding-left:18px; color:rgba(255,255,255,.86); line-height:1.85;">
                  <li>Field wajib: Nama, Email, Kelas.</li>
                  <li>Setelah submit, kamu bisa cek di menu <b>Data Peserta</b>.</li>
                </ul>
                <div class="status" id="statusCreate" aria-live="polite"></div>
              </div>
            </div>

            <div>
              <form id="formCreate" novalidate>
                <div class="grid">
                  <div class="field">
                    <label for="c_nama">Nama Lengkap</label>
                    <input id="c_nama" name="nama" type="text" placeholder="Contoh: Andi Pratama" required />
                  </div>
                  <div class="field">
                    <label for="c_email">Email</label>
                    <input id="c_email" name="email" type="email" placeholder="contoh@email.com" required />
                  </div>
                </div>

                <div class="grid">
                  <div class="field">
                    <label for="c_instansi">Instansi</label>
                    <input id="c_instansi" name="instansi" type="text" placeholder="Sekolah / Kampus / Perusahaan" />
                  </div>
                  <div class="field">
                    <label for="c_kelas">Pilihan Kelas</label>
                    <select id="c_kelas" name="kelas" required>
                      <option value="" selected disabled>Pilih kelas…</option>
                      <option>GAS Dasar</option>
                      <option>GAS + Google Sheets</option>
                      <option>Web App GAS (Form Publik)</option>
                    </select>
                  </div>
                </div>

                <div class="field">
                  <label for="c_catatan">Catatan (opsional)</label>
                  <textarea id="c_catatan" name="catatan" placeholder="Tulis kebutuhan / tujuan ikut pelatihan…"></textarea>
                </div>

                <div class="form-actions">
                  <div class="hint">
                    Submit memakai <code>google.script.run.createTraining</code>.
                  </div>
                  <button class="btn-submit" id="btnCreate" type="submit">
                    <span class="spinner" aria-hidden="true"></span>
                    <span id="txtCreate">Kirim Data</span>
                  </button>
                </div>
              </form>
            </div>

          </div>
        </div>
      </section>
      <footer><div class="container">Tip: Jika form publik, pertimbangkan membatasi akses edit/hapus di versi produksi.</div></footer>
    </div>

    <!-- VIEW: Data (Read/Update/Delete) -->
    <div id="view-data" class="view">
      <section class="block">
        <div class="container">
          <div class="block-header">
            <div>
              <h2>Data Peserta</h2>
              <p>List data dari Google Sheet. Kamu bisa <b>Edit</b> atau <b>Hapus</b> berdasarkan ID.</p>
            </div>
            <button class="pill" data-route="landing" type="button">← Kembali</button>
          </div>

          <div class="table-wrap">
            <div class="table-top">
              <div class="search">
                <input id="q" type="text" placeholder="Cari nama / email / instansi / kelas…" />
                <button class="btn-sm" id="btnRefresh" type="button">Refresh</button>
              </div>
              <div class="actions">
                <button class="btn-sm" data-route="form" type="button">+ Tambah</button>
              </div>
            </div>

            <div class="status" id="statusData" style="margin:12px 12px 0;"></div>

            <div style="overflow:auto;">
              <table aria-label="Tabel peserta">
                <thead>
                  <tr>
                    <th>Nama</th>
                    <th>Timestamp</th>
                    <th>Email</th>
                    <th>Instansi</th>
                    <th>Kelas</th>
                    <th>Catatan</th>
                    <th>Aksi</th>
                  </tr>
                </thead>
                <tbody id="tbody">
                  <!-- rows -->
                </tbody>
              </table>
            </div>
          </div>

        </div>
      </section>
      <footer><div class="container">Data sumber: Google Sheet tab <b>DataPelatihan</b>.</div></footer>
    </div>

    <!-- Modal Edit -->
    <div class="modal" id="modal">
      <div class="modal-card" role="dialog" aria-modal="true" aria-label="Edit Data">
        <div class="modal-head">
          <strong>Edit Data Peserta</strong>
          <button class="btn-sm" id="btnClose" type="button">Tutup</button>
        </div>
        <div class="modal-body">
          <div class="status" id="statusEdit"></div>

          <form id="formEdit" novalidate>
            <input type="hidden" id="e_id" name="id" />

            <div class="grid">
              <div class="field">
                <label for="e_nama">Nama</label>
                <input id="e_nama" name="nama" type="text" required />
              </div>
              <div class="field">
                <label for="e_email">Email</label>
                <input id="e_email" name="email" type="email" required />
              </div>
            </div>

            <div class="grid">
              <div class="field">
                <label for="e_instansi">Instansi</label>
                <input id="e_instansi" name="instansi" type="text" />
              </div>
              <div class="field">
                <label for="e_kelas">Kelas</label>
                <select id="e_kelas" name="kelas" required>
                  <option>GAS Dasar</option>
                  <option>GAS + Google Sheets</option>
                  <option>Web App GAS (Form Publik)</option>
                </select>
              </div>
            </div>

            <div class="field">
              <label for="e_catatan">Catatan</label>
              <textarea id="e_catatan" name="catatan"></textarea>
            </div>

            <div class="form-actions">
              <div class="hint">Update memakai <code>google.script.run.updateTraining</code>.</div>
              <button class="btn-submit" id="btnEdit" type="submit">
                <span class="spinner" aria-hidden="true"></span>
                <span id="txtEdit">Simpan Perubahan</span>
              </button>
            </div>
          </form>

        </div>
      </div>
    </div>

  </main>

  <script>
    /* =========================
       SPA Routing
    ========================== */
    const routes = ["landing","form","data"];

    function setActiveRoute(route){
      if (!routes.includes(route)) route = "landing";

      document.querySelectorAll(".view").forEach(v => v.classList.remove("is-active"));
      document.getElementById("view-" + route).classList.add("is-active");

      document.querySelectorAll("[data-route]").forEach(btn => {
        if (!btn.classList.contains("pill")) return;
        const isCurrent = btn.getAttribute("data-route") === route;
        btn.setAttribute("aria-current", isCurrent ? "page" : "false");
      });

      if (location.hash !== "#" + route) history.replaceState(null, "", "#" + route);
      window.scrollTo({ top: 0, behavior: "smooth" });

      if (route === "data") loadData(); // auto load
    }

    document.addEventListener("click", (e) => {
      const t = e.target.closest("[data-route]");
      if (!t) return;
      e.preventDefault();
      setActiveRoute(t.getAttribute("data-route"));
    });

    function syncFromHash(){
      const hash = (location.hash || "#landing").replace("#", "");
      setActiveRoute(hash);
    }
    window.addEventListener("hashchange", syncFromHash);
    document.getElementById("year").textContent = new Date().getFullYear();
    syncFromHash();

    /* =========================
       UI Helpers
    ========================== */
    function showStatus(el, message, type="ok"){
      el.classList.remove("ok","err","is-show");
      el.classList.add("is-show", type === "ok" ? "ok" : "err");
      el.textContent = message;
    }
    function clearStatus(el){
      el.classList.remove("ok","err","is-show");
      el.textContent = "";
    }
    function setLoading(btn, txtEl, isLoading, loadingText, idleText){
      btn.disabled = isLoading;
      btn.classList.toggle("is-loading", isLoading);
      txtEl.textContent = isLoading ? loadingText : idleText;
    }
    function esc(s){ return String(s ?? "").replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m])); }

    /* =========================
       CREATE
    ========================== */
    const formCreate = document.getElementById("formCreate");
    const statusCreate = document.getElementById("statusCreate");
    const btnCreate = document.getElementById("btnCreate");
    const txtCreate = document.getElementById("txtCreate");

    formCreate.addEventListener("submit", (e) => {
      e.preventDefault();
      clearStatus(statusCreate);

      if (!formCreate.checkValidity()){
        showStatus(statusCreate, "Mohon lengkapi field wajib (Nama, Email, Kelas).", "err");
        return;
      }

      const payload = Object.fromEntries(new FormData(formCreate).entries());

      setLoading(btnCreate, txtCreate, true, "Mengirim...", "Kirim Data");
      showStatus(statusCreate, "⏳ Menyimpan ke Google Sheet...", "ok");

      google.script.run
        .withSuccessHandler((res) => {
          showStatus(statusCreate, "✅ " + (res?.message || "Berhasil ditambahkan.") + " (ID: " + (res?.id || "-") + ")", "ok");
          formCreate.reset();
          setLoading(btnCreate, txtCreate, false, "", "Kirim Data");
        })
        .withFailureHandler((err) => {
          const msg = err && err.message ? err.message : String(err);
          showStatus(statusCreate, "❌ Gagal: " + msg, "err");
          setLoading(btnCreate, txtCreate, false, "", "Kirim Data");
        })
        .createTraining(payload);
    });

    /* =========================
       READ + FILTER
    ========================== */
    const tbody = document.getElementById("tbody");
    const q = document.getElementById("q");
    const btnRefresh = document.getElementById("btnRefresh");
    const statusData = document.getElementById("statusData");

    let cache = [];

    btnRefresh.addEventListener("click", loadData);
    q.addEventListener("input", renderTable);

    function loadData(){
      clearStatus(statusData);
      showStatus(statusData, "⏳ Memuat data...", "ok");

      google.script.run
        .withSuccessHandler((res) => {
          cache = (res && res.data) ? res.data : [];
          showStatus(statusData, "✅ Loaded: " + cache.length + " data.", "ok");
          renderTable();
        })
        .withFailureHandler((err) => {
          const msg = err && err.message ? err.message : String(err);
          showStatus(statusData, "❌ Gagal load: " + msg, "err");
          cache = [];
          renderTable();
        })
        .listTrainings();
    }

    function renderTable(){
      const needle = (q.value || "").toLowerCase().trim();

      const rows = cache.filter(x => {
        if (!needle) return true;
        return [x.nama, x.email, x.instansi, x.kelas, x.catatan].join(" ").toLowerCase().includes(needle);
      });

      if (rows.length === 0){
        tbody.innerHTML = `<tr><td colspan="7" style="padding:16px;color:rgba(255,255,255,.85);">Tidak ada data.</td></tr>`;
        return;
      }

      tbody.innerHTML = rows.map(x => `
        <tr>
          <td><b>${esc(x.nama)}</b><div style="opacity:.75;font-size:12px;margin-top:4px;">ID: ${esc(x.id)}</div></td>
          <td style="white-space:nowrap;">${esc(x.timestamp ? new Date(x.timestamp).toLocaleString() : "")}</td>
          <td>${esc(x.email)}</td>
          <td>${esc(x.instansi)}</td>
          <td>${esc(x.kelas)}</td>
          <td>${esc(x.catatan)}</td>
          <td>
            <div class="actions">
              <button class="btn-sm" type="button" onclick="openEdit('${esc(x.id)}')">Edit</button>
              <button class="btn-sm btn-danger" type="button" onclick="doDelete('${esc(x.id)}')">Hapus</button>
            </div>
          </td>
        </tr>
      `).join("");
    }

    /* =========================
       UPDATE (Modal)
    ========================== */
    const modal = document.getElementById("modal");
    const btnClose = document.getElementById("btnClose");
    const formEdit = document.getElementById("formEdit");
    const statusEdit = document.getElementById("statusEdit");
    const btnEdit = document.getElementById("btnEdit");
    const txtEdit = document.getElementById("txtEdit");

    btnClose.addEventListener("click", closeModal);
    modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); });

    window.openEdit = function(id){
      clearStatus(statusEdit);
      modal.classList.add("is-open");
      showStatus(statusEdit, "⏳ Memuat data...", "ok");

      google.script.run
        .withSuccessHandler((res) => {
          const d = res?.data;
          if (!d) {
            showStatus(statusEdit, "❌ Data tidak ditemukan.", "err");
            return;
          }
          document.getElementById("e_id").value = d.id;
          document.getElementById("e_nama").value = d.nama || "";
          document.getElementById("e_email").value = d.email || "";
          document.getElementById("e_instansi").value = d.instansi || "";
          document.getElementById("e_kelas").value = d.kelas || "GAS Dasar";
          document.getElementById("e_catatan").value = d.catatan || "";
          showStatus(statusEdit, "✅ Silakan edit lalu simpan.", "ok");
        })
        .withFailureHandler((err) => {
          const msg = err && err.message ? err.message : String(err);
          showStatus(statusEdit, "❌ Gagal: " + msg, "err");
        })
        .getTrainingById(id);
    }

    function closeModal(){
      modal.classList.remove("is-open");
      clearStatus(statusEdit);
      formEdit.reset();
    }

    formEdit.addEventListener("submit", (e) => {
      e.preventDefault();
      clearStatus(statusEdit);

      if (!formEdit.checkValidity()){
        showStatus(statusEdit, "Mohon lengkapi field wajib.", "err");
        return;
      }

      const payload = Object.fromEntries(new FormData(formEdit).entries());

      setLoading(btnEdit, txtEdit, true, "Menyimpan...", "Simpan Perubahan");
      showStatus(statusEdit, "⏳ Mengupdate data...", "ok");

      google.script.run
        .withSuccessHandler((res) => {
          showStatus(statusEdit, "✅ " + (res?.message || "Berhasil diupdate."), "ok");
          setLoading(btnEdit, txtEdit, false, "", "Simpan Perubahan");
          loadData();
          setTimeout(closeModal, 600);
        })
        .withFailureHandler((err) => {
          const msg = err && err.message ? err.message : String(err);
          showStatus(statusEdit, "❌ Gagal: " + msg, "err");
          setLoading(btnEdit, txtEdit, false, "", "Simpan Perubahan");
        })
        .updateTraining(payload);
    });

    /* =========================
       DELETE
    ========================== */
    window.doDelete = function(id){
      if (!confirm("Yakin ingin menghapus data ini?\nID: " + id)) return;

      clearStatus(statusData);
      showStatus(statusData, "⏳ Menghapus data...", "ok");

      google.script.run
        .withSuccessHandler((res) => {
          showStatus(statusData, "✅ " + (res?.message || "Berhasil dihapus."), "ok");
          loadData();
        })
        .withFailureHandler((err) => {
          const msg = err && err.message ? err.message : String(err);
          showStatus(statusData, "❌ Gagal hapus: " + msg, "err");
        })
        .deleteTraining(id);
    }
  </script>
</body>
</html>




Tidak ada komentar:

Posting Komentar

Sylabus Struktur Data Using CPP

  https://www.youtube.com/watch?v=PQrkEa5aK3I Minggu Topik Utama Materi Pembahasan 1 Review Dasar C++ & Memory Pointer, reference, alama...