Selasa, 16 Desember 2025

Apps Script - Pembuatan LogBook

 




Buatkan Aplikasi “Jurnal Pelatihan” + Logbook dengan 3 route:

  • #landing

  • #form (Create logbook)

  • #data (Read + Update + Delete)

Backend: Google Apps Script + Google Sheet
Frontend: index.html (SPA-style) dipanggil via doGet().

Code.gs

/** =========================
 * CONFIG
 * ========================= */
const SPREADSHEET_ID = "PASTE_SPREADSHEET_ID_DI_SINI"; // boleh kosong jika bound ke Sheet
const SHEET_NAME = "Logbook";

const HEADERS = [
  "ID",
  "Timestamp",
  "Tanggal",
  "Nama",
  "Email",
  "Program",
  "Topik",
  "DurasiMenit",
  "Kegiatan",
  "Kendala",
  "Rencana",
  "BuktiLink"
];

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

/** =========================
 * CREATE (Logbook)
 * payload: {tanggal,nama,email,program,topik,durasiMenit,kegiatan,kendala,rencana,buktiLink}
 * ========================= */
function createLogbook(payload) {
  payload = payload || {};
  const data = normalize_(payload, true);

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

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

  sheet.appendRow([
    id,
    ts,
    data.tanggal,
    data.nama,
    data.email,
    data.program,
    data.topik,
    data.durasiMenit,
    data.kegiatan,
    data.kendala,
    data.rencana,
    data.buktiLink
  ]);

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

/** =========================
 * READ (list)
 * ========================= */
function listLogbooks() {
  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() : "",
      tanggal: row[idx.Tanggal] || "",
      nama: row[idx.Nama] || "",
      email: row[idx.Email] || "",
      program: row[idx.Program] || "",
      topik: row[idx.Topik] || "",
      durasiMenit: row[idx.DurasiMenit] || "",
      kegiatan: row[idx.Kegiatan] || "",
      kendala: row[idx.Kendala] || "",
      rencana: row[idx.Rencana] || "",
      buktiLink: row[idx.BuktiLink] || ""
    });
  }

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

/** =========================
 * READ (single)
 * ========================= */
function getLogbookById(id) {
  id = String(id || "").trim();
  if (!id) throw new Error("ID kosong.");

  const found = findRowById_(id);
  if (!found) throw new Error("Logbook tidak ditemukan.");

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

/** =========================
 * UPDATE
 * payload: {id, tanggal,nama,email,program,topik,durasiMenit,kegiatan,kendala,rencana,buktiLink}
 * ========================= */
function updateLogbook(payload) {
  payload = payload || {};
  const id = String(payload.id || "").trim();
  if (!id) throw new Error("ID wajib.");

  const data = normalize_(payload, true);

  const sheet = getOrCreateSheet_(getSpreadsheet_(), 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 field (Timestamp tetap)
      sheet.getRange(r + 1, idx.Tanggal + 1).setValue(data.tanggal);
      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.Program + 1).setValue(data.program);
      sheet.getRange(r + 1, idx.Topik + 1).setValue(data.topik);
      sheet.getRange(r + 1, idx.DurasiMenit + 1).setValue(data.durasiMenit);
      sheet.getRange(r + 1, idx.Kegiatan + 1).setValue(data.kegiatan);
      sheet.getRange(r + 1, idx.Kendala + 1).setValue(data.kendala);
      sheet.getRange(r + 1, idx.Rencana + 1).setValue(data.rencana);
      sheet.getRange(r + 1, idx.BuktiLink + 1).setValue(data.buktiLink);

      return { ok: true, message: "Logbook berhasil diupdate.", id };
    }
  }
  throw new Error("ID tidak ditemukan.");
}

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

  const sheet = getOrCreateSheet_(getSpreadsheet_(), 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: "Logbook 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;
  }

  // pastikan header minimal sesuai
  const current = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(String);
  const need = HEADERS.some(h => !current.includes(h));
  if (need) {
    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);
  const fallback = {};
  HEADERS.forEach((h, i) => fallback[h] = i);

  return {
    ID: map.ID ?? fallback.ID,
    Timestamp: map.Timestamp ?? fallback.Timestamp,
    Tanggal: map.Tanggal ?? fallback.Tanggal,
    Nama: map.Nama ?? fallback.Nama,
    Email: map.Email ?? fallback.Email,
    Program: map.Program ?? fallback.Program,
    Topik: map.Topik ?? fallback.Topik,
    DurasiMenit: map.DurasiMenit ?? fallback.DurasiMenit,
    Kegiatan: map.Kegiatan ?? fallback.Kegiatan,
    Kendala: map.Kendala ?? fallback.Kendala,
    Rencana: map.Rencana ?? fallback.Rencana,
    BuktiLink: map.BuktiLink ?? fallback.BuktiLink
  };
}

function normalize_(p, requireCore) {
  const tanggal = String(p.tanggal || "").trim();
  const nama = String(p.nama || "").trim();
  const email = String(p.email || "").trim();
  const program = String(p.program || "").trim();
  const topik = String(p.topik || "").trim();
  const durasiMenit = String(p.durasiMenit || "").trim();
  const kegiatan = String(p.kegiatan || "").trim();

  if (requireCore) {
    if (!tanggal) throw new Error("Tanggal wajib diisi.");
    if (!nama) throw new Error("Nama wajib diisi.");
    if (!email) throw new Error("Email wajib diisi.");
    if (!program) throw new Error("Program wajib diisi.");
    if (!topik) throw new Error("Topik wajib diisi.");
    if (!durasiMenit) throw new Error("Durasi wajib diisi.");
    if (!kegiatan) throw new Error("Kegiatan wajib diisi.");
  }

  return {
    tanggal,
    nama,
    email,
    program,
    topik,
    durasiMenit,
    kegiatan,
    kendala: String(p.kendala || "").trim(),
    rencana: String(p.rencana || "").trim(),
    buktiLink: String(p.buktiLink || "").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() : "",
          tanggal: row[idx.Tanggal] || "",
          nama: row[idx.Nama] || "",
          email: row[idx.Email] || "",
          program: row[idx.Program] || "",
          topik: row[idx.Topik] || "",
          durasiMenit: row[idx.DurasiMenit] || "",
          kegiatan: row[idx.Kegiatan] || "",
          kendala: row[idx.Kendala] || "",
          rencana: row[idx.Rencana] || "",
          buktiLink: row[idx.BuktiLink] || ""
        }
      };
    }
  }
  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>Jurnal Pelatihan — Logbook</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;
    }
    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:900; 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;
      font-weight: 800;
    }
    .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:64ch; }
    .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); }

    /* Blocks */
    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:80ch; }

    .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:70ch; }

    .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(420px, 78vw); 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; }
    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(860px, 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; }
      .split{ grid-template-columns: 1fr; }
      .grid{ grid-template-columns: 1fr; }
      th:nth-child(1), td:nth-child(1){ min-width: 180px; }
      th:nth-child(2), td:nth-child(2){ display:none; } /* hide timestamp */
    }
  </style>
</head>

<body>
  <header>
    <div class="container">
      <div class="topbar">
        <div class="brand" role="banner">
          <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>Jurnal Pelatihan</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 Logbook</button>
          <button class="pill" data-route="data" type="button">Data Logbook</button>
          <button class="cta-mini" data-route="form" type="button">Tulis Logbook</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> 3 route (Landing • Form • Data) + CRUD Logbook</span>
              <h1>Jurnal Pelatihan dengan Logbook Harian yang Rapi</h1>
              <p class="lead">
                Tulis kegiatan pelatihan harian, catat kendala, rencana besok, dan lampirkan bukti (link).
                Semua data tersimpan otomatis di Google Sheet, dan bisa di-edit/hapus dari menu Data.
              </p>

              <div class="hero-actions">
                <button class="btn btn-primary" data-route="form" type="button">
                  Tulis Logbook
                  <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 Logbook</button>
              </div>

              <div class="meta">
                <span><b>Log harian</b> lebih disiplin</span>
                <span><b>CRUD</b> langsung di Sheet</span>
                <span><b>Tanpa reload</b> (SPA)</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);">Logbook Dashboard</span>
              </div>
              <div class="illus-body">
                <!-- Ilustrasi SVG (ringkas) -->
                <svg viewBox="0 0 720 420" width="100%" height="100%" role="img" aria-label="Ilustrasi logbook">
                  <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=".30"/>
                      <stop offset="1" stop-color="#93c5fd" stop-opacity=".12"/>
                    </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)"/>
                  <rect x="120" y="80" rx="22" ry="22" width="480" height="260" fill="rgba(255,255,255,.10)" stroke="rgba(255,255,255,.18)"/>
                  <rect x="150" y="115" rx="14" ry="14" width="420" height="46" fill="rgba(255,255,255,.08)" stroke="rgba(255,255,255,.16)"/>
                  <rect x="150" y="175" rx="14" ry="14" width="420" height="90" fill="rgba(255,255,255,.08)" stroke="rgba(255,255,255,.16)"/>
                  <rect x="150" y="275" rx="14" ry="14" width="260" height="46" fill="url(#g1)"/>
                  <text x="280" y="304" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="14" font-weight="900" fill="white">
                    Simpan Logbook
                  </text>
                </svg>
              </div>
            </div>
          </div>
        </div>
      </section>
      <footer><div class="container">© <span id="year"></span> Jurnal Pelatihan — Google Apps Script</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 Logbook</h2>
              <p>Isi logbook kegiatan pelatihan hari ini. Data akan tersimpan otomatis ke Google Sheet (tab <b>Logbook</b>).</p>
            </div>
            <button class="pill" data-route="landing" type="button">← Kembali</button>
          </div>

          <div class="split">
            <div class="panel">
              <div class="head">
                <strong>Petunjuk</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>Wajib: Tanggal, Nama, Email, Program, Topik, Durasi, Kegiatan.</li>
                  <li>Gunakan <b>Bukti Link</b> untuk Drive/Docs/Foto/Repo.</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_tanggal">Tanggal</label>
                    <input id="c_tanggal" name="tanggal" type="date" required />
                  </div>
                  <div class="field">
                    <label for="c_durasi">Durasi (menit)</label>
                    <input id="c_durasi" name="durasiMenit" type="number" min="1" placeholder="Contoh: 90" required />
                  </div>
                </div>

                <div class="grid">
                  <div class="field">
                    <label for="c_nama">Nama</label>
                    <input id="c_nama" name="nama" type="text" placeholder="Nama lengkap" 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_program">Program</label>
                    <input id="c_program" name="program" type="text" placeholder="Contoh: Pelatihan GAS" required />
                  </div>
                  <div class="field">
                    <label for="c_topik">Topik</label>
                    <input id="c_topik" name="topik" type="text" placeholder="Contoh: doGet & doPost" required />
                  </div>
                </div>

                <div class="field">
                  <label for="c_kegiatan">Kegiatan (ringkas tapi jelas)</label>
                  <textarea id="c_kegiatan" name="kegiatan" placeholder="Apa yang kamu kerjakan hari ini?" required></textarea>
                </div>

                <div class="grid">
                  <div class="field">
                    <label for="c_kendala">Kendala (opsional)</label>
                    <textarea id="c_kendala" name="kendala" placeholder="Apa hambatan yang kamu temui?"></textarea>
                  </div>
                  <div class="field">
                    <label for="c_rencana">Rencana Besok (opsional)</label>
                    <textarea id="c_rencana" name="rencana" placeholder="Apa rencana tindak lanjut besok?"></textarea>
                  </div>
                </div>

                <div class="field">
                  <label for="c_bukti">Bukti Link (opsional)</label>
                  <input id="c_bukti" name="buktiLink" type="url" placeholder="https://drive.google.com/..." />
                </div>

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

          </div>
        </div>
      </section>
      <footer><div class="container">Tip: buat kebiasaan isi logbook tiap selesai sesi pelatihan.</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 Logbook</h2>
              <p>Kelola logbook yang sudah tersimpan: cari, edit, atau hapus data.</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 / program / topik / kegiatan…" />
                <button class="btn-sm" id="btnRefresh" type="button">Refresh</button>
              </div>
              <div class="actions">
                <button class="btn-sm" data-route="form" type="button">+ Tulis Logbook</button>
              </div>
            </div>

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

            <div style="overflow:auto;">
              <table aria-label="Tabel logbook">
                <thead>
                  <tr>
                    <th>Ringkasan</th>
                    <th>Timestamp</th>
                    <th>Tanggal</th>
                    <th>Program</th>
                    <th>Topik</th>
                    <th>Durasi</th>
                    <th>Aksi</th>
                  </tr>
                </thead>
                <tbody id="tbody"></tbody>
              </table>
            </div>
          </div>
        </div>
      </section>
      <footer><div class="container">Data sumber: Google Sheet tab <b>Logbook</b>.</div></footer>
    </div>

    <!-- Modal Edit -->
    <div class="modal" id="modal">
      <div class="modal-card" role="dialog" aria-modal="true" aria-label="Edit Logbook">
        <div class="modal-head">
          <strong>Edit Logbook</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_tanggal">Tanggal</label>
                <input id="e_tanggal" name="tanggal" type="date" required />
              </div>
              <div class="field">
                <label for="e_durasi">Durasi (menit)</label>
                <input id="e_durasi" name="durasiMenit" type="number" min="1" required />
              </div>
            </div>

            <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_program">Program</label>
                <input id="e_program" name="program" type="text" required />
              </div>
              <div class="field">
                <label for="e_topik">Topik</label>
                <input id="e_topik" name="topik" type="text" required />
              </div>
            </div>

            <div class="field">
              <label for="e_kegiatan">Kegiatan</label>
              <textarea id="e_kegiatan" name="kegiatan" required></textarea>
            </div>

            <div class="grid">
              <div class="field">
                <label for="e_kendala">Kendala</label>
                <textarea id="e_kendala" name="kendala"></textarea>
              </div>
              <div class="field">
                <label for="e_rencana">Rencana</label>
                <textarea id="e_rencana" name="rencana"></textarea>
              </div>
            </div>

            <div class="field">
              <label for="e_bukti">Bukti Link</label>
              <input id="e_bukti" name="buktiLink" type="url" />
            </div>

            <div class="form-actions">
              <div class="hint">Update memakai <code>google.script.run.updateLogbook</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;
        btn.setAttribute("aria-current", btn.getAttribute("data-route") === route ? "page" : "false");
      });
      if (location.hash !== "#" + route) history.replaceState(null, "", "#" + route);
      window.scrollTo({ top: 0, behavior: "smooth" });
      if (route === "data") loadData();
    }
    document.addEventListener("click", (e) => {
      const t = e.target.closest("[data-route]");
      if (!t) return;
      e.preventDefault();
      setActiveRoute(t.getAttribute("data-route"));
    });
    function syncFromHash(){ setActiveRoute((location.hash || "#landing").replace("#","")); }
    window.addEventListener("hashchange", syncFromHash);
    document.getElementById("year").textContent = new Date().getFullYear();
    syncFromHash();

    /* ========== helpers ========== */
    function showStatus(el, msg, type="ok"){
      el.classList.remove("ok","err","is-show");
      el.classList.add("is-show", type === "ok" ? "ok" : "err");
      el.textContent = msg;
    }
    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])); }
    function truncate(s, n=90){ s = String(s||""); return s.length>n ? s.slice(0,n-1)+"…" : s; }

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

    // set default tanggal = hari ini
    (function(){
      const d = new Date();
      const yyyy = d.getFullYear();
      const mm = String(d.getMonth()+1).padStart(2,"0");
      const dd = String(d.getDate()).padStart(2,"0");
      document.getElementById("c_tanggal").value = `${yyyy}-${mm}-${dd}`;
    })();

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

      if (!formCreate.checkValidity()){
        showStatus(statusCreate, "Mohon lengkapi field wajib (Tanggal, Nama, Email, Program, Topik, Durasi, Kegiatan).", "err");
        return;
      }

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

      setLoading(btnCreate, txtCreate, true, "Menyimpan...", "Simpan Logbook");
      showStatus(statusCreate, "⏳ Menyimpan ke Google Sheet...", "ok");

      google.script.run
        .withSuccessHandler((res) => {
          showStatus(statusCreate, "✅ " + (res?.message || "Berhasil.") + " (ID: " + (res?.id || "-") + ")", "ok");
          formCreate.reset();
          // set tanggal lagi
          const d = new Date();
          document.getElementById("c_tanggal").value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
          setLoading(btnCreate, txtCreate, false, "", "Simpan Logbook");
        })
        .withFailureHandler((err) => {
          const msg = err && err.message ? err.message : String(err);
          showStatus(statusCreate, "❌ Gagal: " + msg, "err");
          setLoading(btnCreate, txtCreate, false, "", "Simpan Logbook");
        })
        .createLogbook(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 logbook...", "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();
        })
        .listLogbooks();
    }

    function renderTable(){
      const needle = (q.value || "").toLowerCase().trim();
      const rows = cache.filter(x => {
        if (!needle) return true;
        return [x.tanggal, x.nama, x.email, x.program, x.topik, x.kegiatan, x.kendala, x.rencana].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:.78;font-size:12px;margin-top:4px;">${esc(truncate(x.kegiatan, 120))}</div>
            <div style="opacity:.70;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 style="white-space:nowrap;">${esc(x.tanggal)}</td>
          <td>${esc(x.program)}</td>
          <td>${esc(x.topik)}</td>
          <td style="white-space:nowrap;">${esc(x.durasiMenit)} m</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_tanggal").value = d.tanggal || "";
          document.getElementById("e_durasi").value = d.durasiMenit || "";
          document.getElementById("e_nama").value = d.nama || "";
          document.getElementById("e_email").value = d.email || "";
          document.getElementById("e_program").value = d.program || "";
          document.getElementById("e_topik").value = d.topik || "";
          document.getElementById("e_kegiatan").value = d.kegiatan || "";
          document.getElementById("e_kendala").value = d.kendala || "";
          document.getElementById("e_rencana").value = d.rencana || "";
          document.getElementById("e_bukti").value = d.buktiLink || "";

          showStatus(statusEdit, "✅ Silakan edit lalu simpan.", "ok");
        })
        .withFailureHandler((err) => {
          const msg = err && err.message ? err.message : String(err);
          showStatus(statusEdit, "❌ Gagal: " + msg, "err");
        })
        .getLogbookById(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");
        })
        .updateLogbook(payload);
    });

    /* ========== DELETE ========== */
    window.doDelete = function(id){
      if (!confirm("Yakin ingin menghapus logbook 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");
        })
        .deleteLogbook(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...