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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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