Minggu, 21 Desember 2025

Apps Script - E Dagang

 






PROMPT


Buatkan aplikasi SPA seperti Form untuk menambahkan data produk. Field data terdiri dari id
createdAt
updatedAt
name
category
price
stock
unit
description
photoUrl
status
sellerName
sellerContact
tags

Data disimpan  Spreadsheet id : 1it0S1ZHn0xuZufSy0GzkYtFsqhGal8ZsMpnTTnIKWZs. Foto Produk photoUrl disimpan di Folder id : 1HdBtIk8LUgPBf6X-fDDhwh2NtiEAFuFJ

Buatkan UI di index dengan menggunakan html, bootstrap, responsif modern


Code.gs

/**********************
 * CONFIG
 **********************/
const SPREADSHEET_ID = "1it0S1ZHn0xuZufSy0GzkYtFsqhGal8ZsMpnTTnIKWZs";
const SHEET_NAME = "Products";
const FOLDER_ID = "1HdBtIk8LUgPBf6X-fDDhwh2NtiEAFuFJ";

const HEADERS = [
  "id",
  "createdAt",
  "updatedAt",
  "name",
  "category",
  "price",
  "stock",
  "unit",
  "description",
  "photoUrl",
  "status",
  "sellerName",
  "sellerContact",
  "tags"
];

/**********************
 * WEB APP
 **********************/
function doGet() {
  ensureSheet_();
  return HtmlService.createHtmlOutputFromFile("index")
    .setTitle("Ecommerce Product Manager")
    .addMetaTag("viewport", "width=device-width, initial-scale=1");
}

/**********************
 * API (called from google.script.run)
 **********************/
function api(action, payload) {
  payload = payload || {};
  try {
    ensureSheet_();

    switch (action) {
      case "meta":
        return { ok: true, data: { sheet: SHEET_NAME, headers: HEADERS } };

      case "list":
        return { ok: true, data: list_() };

      case "get":
        return { ok: true, data: get_(payload.id) };

      case "create":
        return { ok: true, data: create_(payload) };

      case "update":
        return { ok: true, data: update_(payload) };

      case "delete":
        return { ok: true, data: remove_(payload.id) };

      case "uploadImage":
        return { ok: true, data: uploadImage_(payload.base64, payload.filename) };

      default:
        return { ok: false, message: "Unknown action: " + action };
    }
  } catch (e) {
    return { ok: false, message: String(e && e.message ? e.message : e) };
  }
}

/**********************
 * SHEET HELPERS
 **********************/
function ss_() {
  return SpreadsheetApp.openById(SPREADSHEET_ID);
}

function sh_() {
  return ss_().getSheetByName(SHEET_NAME);
}

function ensureSheet_() {
  const ss = ss_();
  let sh = ss.getSheetByName(SHEET_NAME);
  if (!sh) sh = ss.insertSheet(SHEET_NAME);

  const lastCol = Math.max(sh.getLastColumn(), HEADERS.length);
  const firstRow = sh.getRange(1, 1, 1, lastCol).getValues()[0] || [];
  const current = firstRow.slice(0, HEADERS.length).map(String).join("|").trim();

  if (current !== HEADERS.join("|")) {
    sh.clear();
    sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
    sh.setFrozenRows(1);
  }
}

function uuid_() {
  return Utilities.getUuid();
}

function nowIso_() {
  return new Date().toISOString();
}

function toNumber_(v) {
  if (v === null || v === undefined || v === "") return 0;
  const n = Number(v);
  return isNaN(n) ? 0 : n;
}

function normalizeTags_(tags) {
  if (!tags) return "";
  if (Array.isArray(tags)) return tags.map(t => String(t).trim()).filter(Boolean).join(", ");
  return String(tags)
    .split(",")
    .map(t => t.trim())
    .filter(Boolean)
    .join(", ");
}

function findRowIndexById_(id) {
  const sh = sh_();
  const last = sh.getLastRow();
  if (last < 2) return -1;

  const ids = sh.getRange(2, 1, last - 1, 1).getValues().flat();
  const idx = ids.findIndex(x => String(x) === String(id));
  return idx === -1 ? -1 : idx + 2;
}

/**********************
 * CRUD
 **********************/
function list_() {
  const sh = sh_();
  const lastRow = sh.getLastRow();
  if (lastRow < 2) return [];

  const data = sh.getRange(2, 1, lastRow - 1, HEADERS.length).getValues();

  const out = data
    .filter(r => r.join("").toString().trim() !== "")
    .map(r => {
      const o = {};
      HEADERS.forEach((h, i) => (o[h] = r[i]));
      return o;
    });

  out.sort((a, b) => {
    const bu = String(b.updatedAt || b.createdAt || "");
    const au = String(a.updatedAt || a.createdAt || "");
    return bu.localeCompare(au);
  });

  return out;
}

function get_(id) {
  id = String(id || "").trim();
  if (!id) throw new Error("id wajib");

  const rowIndex = findRowIndexById_(id);
  if (rowIndex === -1) throw new Error("Data tidak ditemukan: " + id);

  const sh = sh_();
  const row = sh.getRange(rowIndex, 1, 1, HEADERS.length).getValues()[0];

  const obj = {};
  HEADERS.forEach((h, i) => (obj[h] = row[i]));
  return obj;
}

function create_(p) {
  const sh = sh_();

  const id = uuid_();
  const createdAt = nowIso_();
  const updatedAt = createdAt;

  const row = [
    id,
    createdAt,
    updatedAt,
    String(p.name || "").trim(),
    String(p.category || "").trim(),
    toNumber_(p.price),
    toNumber_(p.stock),
    String(p.unit || "").trim(),
    String(p.description || "").trim(),
    String(p.photoUrl || "").trim(),
    String(p.status || "ACTIVE").trim(),
    String(p.sellerName || "").trim(),
    String(p.sellerContact || "").trim(),
    normalizeTags_(p.tags)
  ];

  sh.appendRow(row);
  return { id, createdAt, updatedAt };
}

function update_(p) {
  const id = String(p.id || "").trim();
  if (!id) throw new Error("id wajib untuk update");

  const rowIndex = findRowIndexById_(id);
  if (rowIndex === -1) throw new Error("Gagal update, id tidak ditemukan: " + id);

  const sh = sh_();
  const existing = sh.getRange(rowIndex, 1, 1, HEADERS.length).getValues()[0];

  const createdAt = existing[1];
  const updatedAt = nowIso_();

  const row = [
    id,
    createdAt,
    updatedAt,
    String(p.name || "").trim(),
    String(p.category || "").trim(),
    toNumber_(p.price),
    toNumber_(p.stock),
    String(p.unit || "").trim(),
    String(p.description || "").trim(),
    String(p.photoUrl || "").trim(),
    String(p.status || "ACTIVE").trim(),
    String(p.sellerName || "").trim(),
    String(p.sellerContact || "").trim(),
    normalizeTags_(p.tags)
  ];

  sh.getRange(rowIndex, 1, 1, HEADERS.length).setValues([row]);
  return { id, updatedAt };
}

function remove_(id) {
  id = String(id || "").trim();
  if (!id) throw new Error("id wajib untuk delete");

  const rowIndex = findRowIndexById_(id);
  if (rowIndex === -1) throw new Error("Gagal delete, id tidak ditemukan: " + id);

  sh_().deleteRow(rowIndex);
  return { id };
}

/**********************
 * IMAGE UPLOAD
 **********************/
function uploadImage_(base64, filename) {
  if (!base64) throw new Error("base64 kosong");

  filename = filename || ("product_" + Date.now() + ".jpg");
  const parts = String(base64).split(",");
  if (parts.length < 2) throw new Error("format base64 tidak valid");

  const meta = parts[0];
  const b64 = parts[1];

  const mime = detectMime_(meta) || "image/jpeg";
  const bytes = Utilities.base64Decode(b64);
  const blob = Utilities.newBlob(bytes, mime, filename);

  const folder = DriveApp.getFolderById(FOLDER_ID);
  const file = folder.createFile(blob);

  file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);

  const directUrl = "https://lh3.googleusercontent.com/d/" + file.getId();
  return { fileId: file.getId(), url: directUrl, name: file.getName() };
}

function detectMime_(meta) {
  meta = String(meta || "");
  if (meta.indexOf("image/png") !== -1) return "image/png";
  if (meta.indexOf("image/webp") !== -1) return "image/webp";
  if (meta.indexOf("image/gif") !== -1) return "image/gif";
  if (meta.indexOf("image/jpeg") !== -1 || meta.indexOf("image/jpg") !== -1) return "image/jpeg";
  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>Ecommerce Product Manager</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">

  <style>
    :root { --card-radius: 18px; }
    body { background: #f6f7fb; }
    .app-hero{
      background: radial-gradient(1200px 500px at 20% 0%, rgba(13,110,253,.25), transparent 60%),
                  radial-gradient(900px 450px at 100% 10%, rgba(32,201,151,.22), transparent 55%),
                  linear-gradient(180deg, #ffffff, #f6f7fb);
      border: 1px solid rgba(0,0,0,.06);
      border-radius: var(--card-radius);
    }
    .cardx { border-radius: var(--card-radius); }
    .img-preview{
      width:100%;
      aspect-ratio:16/10;
      object-fit:cover;
      border-radius:14px;
      border:1px solid rgba(0,0,0,.08);
      background:#fff;
    }
    .route{ display:none; }
    .route.active{ display:block; }
    .mono{ font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; }
    .badge-soft{
      background: rgba(13,110,253,.10);
      color:#0d6efd;
      border:1px solid rgba(13,110,253,.18);
    }
    .table td,.table th{ vertical-align:middle; }
  </style>
</head>

<body>
<nav class="navbar navbar-expand-lg bg-white border-bottom sticky-top">
  <div class="container">
    <a class="navbar-brand fw-bold" href="#landing">🛒 Product SPA</a>
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
      <span class="navbar-toggler-icon"></span>
    </button>

    <div class="collapse navbar-collapse" id="nav">
      <ul class="navbar-nav ms-auto">
        <li class="nav-item"><a class="nav-link" href="#landing">Landing</a></li>
        <li class="nav-item"><a class="nav-link" href="#form">Tambah/Update</a></li>
        <li class="nav-item"><a class="nav-link" href="#data">Data</a></li>
      </ul>
    </div>
  </div>
</nav>

<div class="container py-4">
  <div id="alertBox" class="alert d-none" role="alert"></div>

  <!-- LANDING -->
  <section id="route-landing" class="route active">
    <div class="app-hero p-4 p-md-5 shadow-sm">
      <div class="row g-4 align-items-center">
        <div class="col-lg-7">
          <span class="badge badge-soft rounded-pill px-3 py-2 mb-3">Google Sheets + Drive</span>
          <h1 class="fw-bold display-6 mb-2">Manajemen Produk E-commerce</h1>
          <p class="text-secondary mb-4">
            SPA responsif untuk input dan kelola produk: foto, stok, status, seller, tags — semuanya tersimpan di Google Sheet.
          </p>
          <div class="d-flex gap-2 flex-wrap">
            <a href="#form" class="btn btn-primary btn-lg">+ Tambah Produk</a>
            <a href="#data" class="btn btn-outline-secondary btn-lg">Lihat Data</a>
          </div>
          <div class="mt-4 small text-secondary">
            <div><span class="fw-semibold">Sheet:</span> <span class="mono">Products</span></div>
            <div><span class="fw-semibold">Kolom:</span> id, createdAt, updatedAt, name, category, price, stock, unit, description, photoUrl, status, sellerName, sellerContact, tags</div>
          </div>
        </div>

        <div class="col-lg-5">
          <div class="bg-white cardx p-3 border shadow-sm">
            <div class="d-flex justify-content-between align-items-center mb-2">
              <div class="fw-semibold">Quick Actions</div>
              <span class="text-secondary small">Connection</span>
            </div>
            <div class="d-grid gap-2">
              <a class="btn btn-outline-primary" href="#form">Form Produk</a>
              <a class="btn btn-outline-success" href="#data">Tabel Data</a>
              <button class="btn btn-outline-dark" id="btnInitLoad">Cek Koneksi</button>
            </div>
            <div class="mt-3 small text-secondary" id="metaInfo"></div>
          </div>
        </div>
      </div>
    </div>
  </section>

  <!-- FORM -->
  <section id="route-form" class="route">
    <div class="row g-4">
      <div class="col-lg-7">
        <div class="bg-white border shadow-sm cardx p-4">
          <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
            <h2 class="h4 fw-bold m-0">Form Produk</h2>
            <div class="d-flex gap-2">
              <button class="btn btn-outline-secondary" id="btnReset" type="button">Reset</button>
              <a class="btn btn-outline-dark" href="#data">Ke Data</a>
            </div>
          </div>

          <form id="productForm" class="row g-3">
            <input type="hidden" id="id" />
            <input type="hidden" id="photoUrl" />

            <div class="col-md-8">
              <label class="form-label">Nama Produk</label>
              <input class="form-control" id="name" required placeholder="Contoh: Kaos Oversize Premium">
            </div>

            <div class="col-md-4">
              <label class="form-label">Status</label>
              <select class="form-select" id="status" required>
                <option value="ACTIVE">ACTIVE</option>
                <option value="INACTIVE">INACTIVE</option>
                <option value="DRAFT">DRAFT</option>
              </select>
            </div>

            <div class="col-md-6">
              <label class="form-label">Kategori</label>
              <input class="form-control" id="category" required placeholder="Fashion / Elektronik / dll">
            </div>

            <div class="col-md-3">
              <label class="form-label">Harga</label>
              <input type="number" min="0" class="form-control" id="price" required placeholder="150000">
            </div>

            <div class="col-md-3">
              <label class="form-label">Stok</label>
              <input type="number" min="0" class="form-control" id="stock" required placeholder="20">
            </div>

            <div class="col-md-4">
              <label class="form-label">Unit</label>
              <input class="form-control" id="unit" required placeholder="pcs / box / pack">
            </div>

            <div class="col-md-4">
              <label class="form-label">Seller Name</label>
              <input class="form-control" id="sellerName" required placeholder="Nama Toko">
            </div>

            <div class="col-md-4">
              <label class="form-label">Seller Contact</label>
              <input class="form-control" id="sellerContact" required placeholder="WA / Email / IG">
            </div>

            <div class="col-12">
              <label class="form-label">Tags (pisahkan dengan koma)</label>
              <input class="form-control" id="tags" placeholder="best seller, murah, original">
            </div>

            <div class="col-12">
              <label class="form-label">Deskripsi</label>
              <textarea class="form-control" id="description" rows="4" placeholder="Detail produk..." required></textarea>
            </div>

            <div class="col-12">
              <label class="form-label">Foto Produk</label>
              <div class="d-flex gap-2 flex-wrap align-items-center">
                <input type="file" id="photoFile" class="form-control" accept="image/*">
                <button class="btn btn-outline-primary" type="button" id="btnUploadPhoto">Upload</button>
                <span class="small text-secondary">Upload dulu agar <span class="mono">photoUrl</span> terisi.</span>
              </div>
            </div>

            <div class="col-12 d-flex gap-2 flex-wrap">
              <button class="btn btn-primary" type="submit" id="btnSubmit">Simpan Produk</button>
              <button class="btn btn-outline-danger d-none" type="button" id="btnDelete">Hapus</button>
            </div>

            <div class="col-12 small text-secondary">
              <div><span class="fw-semibold">ID:</span> <span class="mono" id="idView"></span></div>
              <div><span class="fw-semibold">CreatedAt:</span> <span class="mono" id="createdAtView"></span></div>
              <div><span class="fw-semibold">UpdatedAt:</span> <span class="mono" id="updatedAtView"></span></div>
            </div>
          </form>
        </div>
      </div>

      <div class="col-lg-5">
        <div class="bg-white border shadow-sm cardx p-4">
          <div class="d-flex justify-content-between align-items-center mb-2">
            <div class="fw-semibold">Preview Foto</div>
            <span class="small text-secondary" id="photoUrlView"></span>
          </div>
          <img id="photoPreview" class="img-preview" alt="Preview" src="">
          <div class="mt-3 small text-secondary">Tips: untuk edit, klik <b>Edit</b> dari menu Data.</div>
        </div>
      </div>
    </div>
  </section>

  <!-- DATA -->
  <section id="route-data" class="route">
    <div class="bg-white border shadow-sm cardx p-4">
      <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
        <h2 class="h4 fw-bold m-0">Data Produk</h2>
        <div class="d-flex gap-2 flex-wrap">
          <input class="form-control" style="min-width:220px" id="q" placeholder="Search name/category/seller/tags...">
          <select class="form-select" style="min-width:160px" id="filterStatus">
            <option value="">All Status</option>
            <option value="ACTIVE">ACTIVE</option>
            <option value="INACTIVE">INACTIVE</option>
            <option value="DRAFT">DRAFT</option>
          </select>
          <button class="btn btn-outline-primary" id="btnReload" type="button">Reload</button>
          <a class="btn btn-primary" href="#form">+ Tambah</a>
        </div>
      </div>

      <div id="loading" class="text-secondary small d-none">Loading...</div>
      <div id="empty" class="text-secondary small d-none">Belum ada data.</div>

      <div class="table-responsive">
        <table class="table table-hover align-middle">
          <thead>
            <tr>
              <th>Produk</th>
              <th>Kategori</th>
              <th>Harga</th>
              <th>Stok</th>
              <th>Status</th>
              <th>Seller</th>
              <th style="width:160px;">Aksi</th>
            </tr>
          </thead>
          <tbody id="tbody"></tbody>
        </table>
      </div>
    </div>
  </section>

</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
  const $ = (id) => document.getElementById(id);

  // ✅ FIX: aman tanpa replaceAll + selalu string
  function escapeHtml(v) {
    const s = String(v ?? "");
    return s
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }

  function showAlert(msg, type="success") {
    const el = $("alertBox");
    el.className = `alert alert-${type}`;
    el.textContent = msg;
    el.classList.remove("d-none");
    setTimeout(() => el.classList.add("d-none"), 2600);
  }

  function rupiah(n) {
    const x = Number(n || 0);
    return x.toLocaleString("id-ID");
  }

  function gas(action, payload = {}) {
    return new Promise((resolve, reject) => {
      google.script.run
        .withSuccessHandler(resolve)
        .withFailureHandler(err => reject(err))
        .api(action, payload);
    });
  }

  // ===== Router =====
  const routeIds = ["landing","form","data"];
  function setRoute(hash) {
    const r = (hash || "#landing").replace("#","");
    routeIds.forEach(x => $("route-"+x).classList.toggle("active", x === r));
    if (r === "data") loadTable();
  }
  window.addEventListener("hashchange", () => setRoute(location.hash));
  setRoute(location.hash);

  // ===== Landing meta =====
  $("btnInitLoad").addEventListener("click", async () => {
    try {
      const res = await gas("meta");
      if (!res.ok) throw new Error(res.message);
      $("metaInfo").textContent = `OK ✅ Sheet: ${res.data.sheet}, Headers: ${res.data.headers.length} kolom`;
      showAlert("Koneksi backend OK ✅");
    } catch (e) {
      showAlert(e.message || String(e), "danger");
    }
  });

  // ===== Form =====
  function resetForm() {
    $("id").value = "";
    $("photoUrl").value = "";
    $("name").value = "";
    $("category").value = "";
    $("price").value = "";
    $("stock").value = "";
    $("unit").value = "";
    $("description").value = "";
    $("status").value = "ACTIVE";
    $("sellerName").value = "";
    $("sellerContact").value = "";
    $("tags").value = "";
    $("photoFile").value = "";

    $("idView").textContent = "—";
    $("createdAtView").textContent = "—";
    $("updatedAtView").textContent = "—";
    $("photoUrlView").textContent = "—";
    $("photoPreview").src = "";

    $("btnSubmit").textContent = "Simpan Produk";
    $("btnDelete").classList.add("d-none");
  }

  function fillForm(d) {
    $("id").value = d.id || "";
    $("name").value = d.name || "";
    $("category").value = d.category || "";
    $("price").value = d.price ?? 0;
    $("stock").value = d.stock ?? 0;
    $("unit").value = d.unit || "";
    $("description").value = d.description || "";
    $("photoUrl").value = d.photoUrl || "";
    $("status").value = d.status || "ACTIVE";
    $("sellerName").value = d.sellerName || "";
    $("sellerContact").value = d.sellerContact || "";
    $("tags").value = d.tags || "";

    $("idView").textContent = d.id || "—";
    $("createdAtView").textContent = d.createdAt || "—";
    $("updatedAtView").textContent = d.updatedAt || "—";
    $("photoUrlView").textContent = d.photoUrl ? "photoUrl terisi ✅" : "—";
    $("photoPreview").src = d.photoUrl || "";

    $("btnSubmit").textContent = "Update Produk";
    $("btnDelete").classList.remove("d-none");
  }

  $("btnReset").addEventListener("click", resetForm);

  // ===== Upload Photo =====
  function fileToBase64(file) {
    return new Promise((resolve, reject) => {
      const r = new FileReader();
      r.onload = () => resolve(r.result);
      r.onerror = reject;
      r.readAsDataURL(file);
    });
  }

  $("btnUploadPhoto").addEventListener("click", async () => {
    const file = $("photoFile").files && $("photoFile").files[0];
    if (!file) return showAlert("Pilih file foto dulu.", "warning");

    try {
      $("btnUploadPhoto").disabled = true;
      $("btnUploadPhoto").textContent = "Uploading...";

      const base64 = await fileToBase64(file);
      const safeName = (file.name || "product.jpg").replace(/[^\w.\-]+/g, "_");
      const res = await gas("uploadImage", { base64, filename: `${Date.now()}_${safeName}` });
      if (!res.ok) throw new Error(res.message);

      $("photoUrl").value = res.data.url;
      $("photoPreview").src = res.data.url;
      $("photoUrlView").textContent = "photoUrl terisi ✅";
      showAlert("Foto berhasil diupload ✅");
    } catch (e) {
      showAlert(e.message || String(e), "danger");
    } finally {
      $("btnUploadPhoto").disabled = false;
      $("btnUploadPhoto").textContent = "Upload";
    }
  });

  // ===== Create/Update =====
  $("productForm").addEventListener("submit", async (e) => {
    e.preventDefault();

    const payload = {
      id: $("id").value.trim(),
      name: $("name").value.trim(),
      category: $("category").value.trim(),
      price: Number($("price").value || 0),
      stock: Number($("stock").value || 0),
      unit: $("unit").value.trim(),
      description: $("description").value.trim(),
      photoUrl: $("photoUrl").value.trim(),
      status: $("status").value.trim(),
      sellerName: $("sellerName").value.trim(),
      sellerContact: $("sellerContact").value.trim(),
      tags: $("tags").value.trim()
    };

    try {
      const isEdit = !!payload.id;
      const res = await gas(isEdit ? "update" : "create", payload);
      if (!res.ok) throw new Error(res.message);

      showAlert(isEdit ? "Produk diupdate ✅" : "Produk dibuat ✅");
      resetForm();
      location.hash = "#data";
    } catch (err) {
      showAlert(err.message || String(err), "danger");
    }
  });

  $("btnDelete").addEventListener("click", async () => {
    const id = $("id").value.trim();
    if (!id) return;
    if (!confirm("Yakin hapus produk ini?")) return;

    try {
      const res = await gas("delete", { id });
      if (!res.ok) throw new Error(res.message);
      showAlert("Produk dihapus 🗑️");
      resetForm();
      location.hash = "#data";
    } catch (e) {
      showAlert(e.message || String(e), "danger");
    }
  });

  // ===== Table =====
  let cache = [];

  function applyFilter() {
    const q = $("q").value.trim().toLowerCase();
    const st = $("filterStatus").value.trim();

    let data = cache.slice();
    if (st) data = data.filter(x => String(x.status || "").toUpperCase() === st);

    if (q) {
      data = data.filter(x => {
        const blob = [
          x.name, x.category, x.sellerName, x.sellerContact, x.tags
        ].join(" ").toLowerCase();
        return blob.includes(q);
      });
    }
    renderTable(data);
  }

  function renderTable(data) {
    const tbody = $("tbody");
    tbody.innerHTML = "";

    $("empty").classList.toggle("d-none", data.length !== 0);

    data.forEach(row => {
      const id = String(row.id || "");
      const photo = String(row.photoUrl || "");

      const status = String(row.status || "").toUpperCase();
      const badgeClass =
        status === "ACTIVE" ? "text-bg-success" :
        status === "DRAFT" ? "text-bg-warning" :
        "text-bg-secondary";

      const tr = document.createElement("tr");
      tr.innerHTML = `
        <td>
          <div class="d-flex gap-3 align-items-center">
            <img src="${escapeHtml(photo)}" onerror="this.style.display='none'"
                 style="width:54px;height:40px;object-fit:cover;border-radius:10px;border:1px solid rgba(0,0,0,.08);background:#fff">
            <div>
              <div class="fw-semibold">${escapeHtml(row.name)}</div>
              <div class="small text-secondary mono">${escapeHtml(id)}</div>
            </div>
          </div>
        </td>
        <td>${escapeHtml(row.category)}</td>
        <td>Rp ${rupiah(row.price)}</td>
        <td>${rupiah(row.stock)} ${escapeHtml(row.unit)}</td>
        <td><span class="badge ${badgeClass}">${escapeHtml(row.status)}</span></td>
        <td>
          <div class="fw-semibold">${escapeHtml(row.sellerName)}</div>
          <div class="small text-secondary">${escapeHtml(row.sellerContact)}</div>
        </td>
        <td>
          <div class="d-flex gap-2">
            <button class="btn btn-sm btn-outline-primary" data-edit="${escapeHtml(id)}">Edit</button>
            <button class="btn btn-sm btn-outline-danger" data-del="${escapeHtml(id)}">Hapus</button>
          </div>
        </td>
      `;
      tbody.appendChild(tr);
    });
  }

  async function loadTable() {
    try {
      $("loading").classList.remove("d-none");
      $("empty").classList.add("d-none");

      const res = await gas("list");
      if (!res.ok) throw new Error(res.message);

      cache = res.data || [];
      applyFilter();
    } catch (e) {
      showAlert(e.message || String(e), "danger");
    } finally {
      $("loading").classList.add("d-none");
    }
  }

  $("btnReload").addEventListener("click", loadTable);
  $("q").addEventListener("input", applyFilter);
  $("filterStatus").addEventListener("change", applyFilter);

  document.addEventListener("click", async (e) => {
    const editId = e.target.getAttribute("data-edit");
    const delId = e.target.getAttribute("data-del");

    try {
      if (editId) {
        const res = await gas("get", { id: editId });
        if (!res.ok) throw new Error(res.message);
        fillForm(res.data);
        location.hash = "#form";
      }
      if (delId) {
        if (!confirm("Yakin hapus produk ini?")) return;
        const res = await gas("delete", { id: delId });
        if (!res.ok) throw new Error(res.message);
        showAlert("Produk dihapus 🗑️");
        loadTable();
      }
    } catch (err) {
      showAlert(err.message || String(err), "danger");
    }
  });

  // init
  resetForm();
</script>
</body>
</html>

https://its-dualtrack.blogspot.com/2025/12/ruang-dagang.html

https://chatgpt.com/c/6948837d-795c-8320-82e5-c284454fd544

Tidak ada komentar:

Posting Komentar

Apps Script - E Dagang

  https://script.google.com/macros/s/AKfycbxHrfhWhJIbvikIS5VIan6hoSkmqw-KwJOaHqM8HpF74VVRFYhMQzDqpXiIr58oRXR_Qg/exec https://docs.google.com...