Kamis, 16 April 2026

Apps Script - Dashboard CRUD

 




Prompt Dashboard CRUD Google Spreadsheet & Drive

Gunakan prompt ini untuk membangun kembali aplikasi serupa dengan spesifikasi lengkap:

Peran:

Bertindaklah sebagai Senior Full-stack Developer yang ahli dalam Google Apps Script (GAS).

Deskripsi Proyek:

Buatlah aplikasi web dashboard CRUD (Create, Read, Update, Delete) yang terhubung dengan Google Spreadsheet sebagai database dan Google Drive sebagai penyimpanan gambar.

Spesifikasi Teknis:

  1. Backend (Code.gs):

    • Spreadsheet Integration: Kelola data pada sheet spesifik (misal: "Sheet1") dengan kolom: id, name, description, price, sizes, colors, category, images, isNew.

    • CRUD Functions: Implementasikan readData(), createData(obj), updateData(row, obj), dan deleteData(row).

    • Drive Upload: Buat fungsi uploadFileToDrive(base64, fileName) yang menyimpan file ke Folder ID tertentu, mengatur izin menjadi publik (view only), dan mengembalikan URL format https://lh3.googleusercontent.com/d/FILE_ID.

    • Setup Database: Fungsi untuk membuat header otomatis dan memformat baris pertama (bold & frozen).

  2. Frontend (dashboard.html):

    • UI Framework: Tailwind CSS untuk desain modern dan responsif.

    • Icons & Alerts: Lucide Icons untuk tombol aksi dan SweetAlert2 untuk notifikasi/konfirmasi.

    • Fitur Utama:

      • Tabel dengan urutan kolom: ID, Name, Description, Price, Size, Colors, Category, Images.

      • Kolom Images harus menampilkan thumbnail yang bisa diklik untuk membuka URL gambar asli.

      • Tombol "Tambah Produk" yang membuka modal dengan fitur Upload Gambar (mengonversi file ke Base64).

      • Tombol "Aksi" di setiap baris: View (Modal read-only detail), Edit (Modal form), dan Delete (Konfirmasi).

    • UX: Indikator loading spinner saat proses data, error handling yang ramah pengguna, dan mode simulasi (mocking) jika objek google.script.run tidak terdeteksi.

  3. Manifest (appsscript.json):

    • Tambahkan oauthScopes yang diperlukan untuk drive, spreadsheets, dan script.external_request.

Instruksi Implementasi:

  1. Berikan kode lengkap yang siap pakai (single file untuk frontend).

  2. Sertakan komentar penjelasan pada bagian logika krusial seperti konversi Base64 dan manipulasi DOM dinamis.

  3. Pastikan arsitekturnya ringan agar cepat diakses melalui URL Web App.

Android - Aplikasi 2

 




Portal Kerja

 


1. Deskripsi Umum

SIAPkerja merupakan portal induk dan ekosistem digital yang berfungsi sebagai platform layanan publik serta aktivitas di bidang ketenagakerjaan, baik untuk tingkat pusat maupun daerah. Aplikasi ini bertujuan untuk membangun ekosistem digital ketenagakerjaan yang terintegrasi dan memudahkan pemangku kepentingan (masyarakat, perusahaan, lembaga, dan K/L) dalam mengakses layanan.

2. Arsitektur dan Teknologi Utama

  • Microservices: Aplikasi dibangun menggunakan konsep micro services di mana setiap layanan saling terintegrasi namun tetap mewujudkan satu kesatuan data.

  • Single Sign On (SSO): Sistem menerapkan SSO yang memungkinkan pengguna hanya memerlukan satu akun dan satu kali login untuk mengakses seluruh layanan di dalam ekosistem.

3. Fitur Utama dan Alur Pengguna

A. Pendaftaran Akun (Verifikasi Identitas)

  • Integrasi Dukcapil: Sistem melakukan pengecekan data Nomor Induk Kependudukan (NIK) dan nama ibu kandung langsung ke database Dukcapil Pusat.

  • Validasi Kontak: Pengguna wajib menggunakan alamat email dan nomor handphone yang aktif untuk keperluan akun.

B. Manajemen Profil Ketenagakerjaan

Aplikasi menyediakan sembilan langkah pengisian profil yang berfungsi sebagai portofolio online bagi pengguna. Modul-modul tersebut meliputi:

  • Biodata dan Foto: Pengguna dapat mengunggah foto profil secara langsung atau melalui upload file. Data biodata mencakup NIK, tanggal lahir, jenis kelamin, status perkawinan, dan alamat sesuai KTP.

  • Pengalaman Kerja: Modul untuk mencatat riwayat pekerjaan yang dapat digunakan perusahaan untuk menilai kandidat.

  • Pelatihan: Modul pengisian riwayat pelatihan, mencakup nama lembaga, program pelatihan, kejuruan, sub-kejuruan, durasi, hingga unggah sertifikat pelatihan.

  • Pencapaian/Sertifikasi: Fitur untuk menambahkan sertifikasi atau prestasi guna memperkuat portofolio profil.

  • Keahlian dan Bahasa: Pengguna dapat menambahkan daftar keahlian (contoh: Backend Development, SQL) dan tingkat kemahiran bahasa untuk mendapatkan rekomendasi lowongan yang relevan.

4. Output Aplikasi

  • Kartu SIAPkerja (SIAPkerja-ID): Setelah profil lengkap, pengguna akan mendapatkan kartu keanggotaan digital yang dapat diakses melalui portal sebagai identitas ketenagakerjaan mereka.

Prompt Utama (System Role & Context)

"Bertindaklah sebagai Senior Software Architect dan Full-Stack Developer. Saya ingin membangun sebuah platform ekosistem digital ketenagakerjaan bernama 'SIAPkerja-ID' yang berbasis pada dokumen panduan resmi Kementerian Ketenagakerjaan.

Tujuan Aplikasi: Menjadi portal induk layanan publik ketenagakerjaan dengan sistem terintegrasi (Satu Data).

Spesifikasi Teknis Utama:

  1. Arsitektur: Gunakan pendekatan Microservices yang saling terintegrasi.

  2. Autentikasi: Terapkan Single Sign On (SSO) agar satu akun bisa mengakses semua layanan (Skillhub, Karirhub, dll).

  3. Integrasi Data: Memerlukan modul validasi NIK yang terhubung dengan database kependudukan (mock API Dukcapil).

Fitur yang Harus Ada:

  1. Multi-Step Profile Wizard (9 Langkah):

    • Biodata (Integrasi NIK), Foto Profil, Pengalaman Kerja, Pendidikan, Pelatihan, Sertifikasi, Pencapaian, Kemampuan Bahasa, dan Keahlian Teknis.

  2. Dashboard Pengguna: Menampilkan progress kelengkapan profil.

  3. Digital Card Generator: Menghasilkan 'Kartu SIAPkerja' (ID Digital) secara dinamis setelah profil 100% lengkap.

  4. Matchmaking Engine: Rekomendasi lowongan kerja berdasarkan input 'Keahlian' dan 'Bahasa' di langkah ke-8 dan 9.

Tugas Anda:

  1. Buatlah Skema Database (ERD) yang mampu menangani data profil yang kompleks tersebut.

  2. Susun Struktur Folder Project untuk arsitektur yang scalable.

  3. Berikan contoh kode Backend (API) untuk proses validasi pendaftaran akun dan Frontend (UI) menggunakan Tailwind CSS untuk form wizard 9 langkah tersebut agar terlihat modern dan responsif.

Mari mulai dengan memberikan outline arsitektur dan skema database-nya terlebih dahulu."


Prompt Tambahan (Opsional - Jika ingin lebih spesifik)

Jika Anda ingin fokus pada bagian tertentu, gunakan prompt ini:

  • Untuk Desain UI/UX (Tailwind/Bootstrap):

    "Buatkan kode HTML/CSS menggunakan Tailwind CSS untuk halaman 'Langkah 9: Keahlian'. Form harus memiliki fitur 'Tag Input' untuk memasukkan keahlian seperti 'SQL', 'DevOps', dan 'Backend Development' sesuai panduan SIAPkerja, lengkap dengan tombol 'Simpan dan Kirim' yang elegan."

  • Untuk Logika Backend (Node.js/Python/PHP):

    "Buatkan fungsi validasi pendaftaran di backend. Fungsi ini harus menerima NIK dan Nama Ibu Kandung, lalu melakukan pengecekan ke mock API Dukcapil. Jika valid, buatkan sesi SSO untuk pengguna tersebut."

  • Untuk Fitur Kartu Digital:

    "Buatkan komponen React atau fungsi JavaScript untuk mengenerate Kartu SIAPkerja-ID dalam format kartu digital yang menampilkan Nama, NIK, dan QR Code unik setelah pengguna menyelesaikan seluruh profilnya."


Tips: Karena Anda adalah seorang spesialis Google Apps Script (GAS), Anda bisa memodifikasi bagian teknologi di atas menjadi: "Gunakan Google Apps Script sebagai backend, Google Sheets sebagai database, dan Bootstrap untuk frontend (MPA/SPA)." sesuai dengan profil keahlian Anda.


https://panduan.kemnaker.go.id/

https://www.bpjsketenagakerjaan.go.id/download/Brosur_Panduan_Penggunaan_Siap_Kerja.pdf

https://kemnaker.go.id/

https://panduan.kemnaker.go.id/panduan/37

https://drive.google.com/file/d/1gRxxsgZc8f7HxkfXTWOS7VnC0DcdTV5D/view?usp=drive_link

https://drive.google.com/file/d/17zGN-OyLZFy2NSVWU-4VQgqOcmzqomxJ/view?usp=drive_link

Firebase Studio - Membuat Aplikasi 1

 









https://www.youtube.com/watch?v=S-ugu7qlBT8

https://www.youtube.com/watch?v=kV7skm1NXno

https://www.youtube.com/watch?v=C2P707v_gig&list=PL2M5QMIFassoTtmsA-KnKvIF79hHM1P-1

https://www.youtube.com/watch?v=_KgCFwWTORI&list=PLJTyZKho7eUhpYMoKmYq9aeU20dRiWVes

https://www.youtube.com/watch?v=58IUNjXSwp8&list=PLXTfmstF10qBY1wEY23qnGDnf8yYeHe2o


Firebase - Tutorial for Beginners Task Sync

 




























Rabu, 15 April 2026

Prompt untuk Aplikasi



Aplikasi fashion boutique

Create an e-commerce app for a high-end women's fashion boutique called 'The Wardrobe.' 

The target audience is style-conscious women aged 25-40. 

The design should be minimalist and elegant, using a black, white and gold color palette with a sophisticated serif font.

Key features must include: A homepage with a large hero image showcasing the latest collection. Product pages with multiple high-resolution images, size and color options and detailed descriptions.A 'New Arrivals' section and curated 'Shop the Look' collections. A secure, one-page checkout process with Apple Pay and credit card options. User accounts for saving addresses and viewing order history.


Aplikasi Kesehatan

Build a meditation and mindfulness app named 'Stillness.' 

It's for beginners looking to reduce stress and improve sleep. 

The vibe should be calm and peaceful, using a soft color palette of blues, greens and grays with a clean, sans-serif font.

Key features must include: A library of guided meditations organized by categories like 'Sleep,' 'Anxiety,' and 'Focus.' A simple audio player with controls for play, pause and volume. A progress tracker that shows daily streaks and total minutes meditated.  A 'Favorites' feature for users to save their most-loved sessions. Gentle, optional push notifications to remind users to meditate each day.

Aplikasi Learning

Create a language learning app called 'LinguaSnap' for travelers who want to learn basic conversational phrases. 

The design should be bright, colorful and encouraging, using flashcards and fun illustrations.

Key features must include: Language modules for Spanish, French, and Italian, broken down into short, 5-minute lessons.Interactive flashcards with audio pronunciations spoken by a native speaker. Simple quizzes at the end of each lesson to test knowledge. A progress bar for each language to show completion.A 'Phrasebook' section where users can save important phrases for quick access.

Aplikasi Productivity

Design a minimalist task manager app called 'Flow.' 

It's for busy professionals who need to organize their daily to-do lists. The interface should be clean and distraction-free, with a dark mode option. 

Key features must include: A main screen where users can add and check off tasks.The ability to set due dates and reminders for each task. A feature to create different lists, like 'Work,' 'Personal,' and 'Groceries.' A simple drag-and-drop interface to reorder tasks based on priority. A 'Completed' view to see all finished tasks.

Aplikasi Social Media

Build a social network app called 'CraftConnect' for people who love knitting and crocheting. 

The audience is crafters of all ages looking to share their projects and find patterns. The design should feel cozy and creative, like a digital craft circle.

Key features must include: User profiles where users can post photos of their projects. A main feed that shows posts from people they follow.A 'Discover' page to find new creators and trending patterns. The ability to 'like' and comment on posts. A direct messaging feature for users to connect with each other.



 

Wardrobe

 


https://script.google.com/u/0/home/projects/1CFt-3Vgb_upRwPjPP7U6eW-Xps4xMoI96RYzu9rMgrf8t7d7cWxwlw2t/edit

https://docs.google.com/spreadsheets/d/1ixFMMNRcV5k1zYw3a0gcZN4UEQ2kV4BjqYRvhP9uD-c/edit?gid=0#gid=0


https://drive.google.com/drive/folders/1GknfnEvkyU-Do8yKqSmsRZXQvKSCSrsQ




/**
 * KONFIGURASI DATABASE
 */
const SPREADSHEET_ID = '1ixFMMNRcV5k1zYw3a0gcZN4UEQ2kV4BjqYRvhP9uD-c';
const FOLDER_ID = '1s9QXPrVaezDg6YAv3uzysm4Wfkpgg5y8';

/**
 * FUNGSI DEBUG UNTUK MEMAKSA OTORISASI DRIVE
 * Jalankan fungsi ini sekali di editor jika Anda mendapatkan error izin.
 */
function debug_forceAuth() {
  const folder = DriveApp.getFolderById(FOLDER_ID);
  const testFile = folder.createFile("test_auth.txt", "Tes Otorisasi Berhasil");
  testFile.setTrashed(true); // Hapus file tes setelah dibuat
  console.log("Otorisasi Drive berhasil dideteksi dan diberikan.");
}



PROMPT

Create an e-commerce app for a high-end women's fashion boutique called 'The Wardrobe.'

The target audience is style-conscious women aged 25-40.

The design should be minimalist and elegant, using a black, white and gold color palette with a sophisticated serif font.

Key features must include: A homepage with a large hero image showcasing the latest collection. Product pages with multiple high-resolution images, size and color options and detailed descriptions.A 'New Arrivals' section and curated 'Shop the Look' collections. A secure, one-page checkout process with Apple Pay and credit card options. User accounts for saving addresses and viewing order history.





Dokumentasi Teknis Frontend: The Wardrobe

Dokumen ini menjelaskan arsitektur, teknologi, dan komponen antarmuka pengguna (UI) yang digunakan dalam aplikasi butik "The Wardrobe". Frontend dibangun dengan pendekatan modern minimalist yang mengutamakan performa dan integrasi langsung dengan Google Apps Script.

1. Tumpukan Teknologi (Tech Stack)

Aplikasi ini dikembangkan sebagai file HTML mandiri (Single HTML File) yang memuat seluruh logika dan gaya tanpa memerlukan proses kompilasi berat atau server Node.js terpisah, sehingga sangat mudah untuk disematkan (embedded) ke dalam platform seperti Google Sites:

  • HTML5 & React 18 (Standalone): Menggunakan pustaka React melalui CDN dan Babel Standalone untuk mengeksekusi sintaks JSX secara langsung di sisi klien (browser).

  • Tailwind CSS: Framework CSS berbasis utilitas yang dimuat via CDN untuk desain responsif dan kustomisasi palet warna (Black, White, Gold).

  • Lucide React: Library ikon berbasis SVG untuk antarmuka yang bersih dan ringan.

  • Google Apps Script API: Jembatan komunikasi menggunakan google.script.run untuk memanggil fungsi backend di Code.gs.

2. Arsitektur Navigasi (MPA Simulation)

Meskipun berjalan dalam satu file HTML, aplikasi ini mensimulasikan Multi-Page Application (MPA) untuk memberikan pengalaman navigasi yang terstruktur:

  • Stateful Routing: Menggunakan state view untuk merender komponen halaman yang berbeda secara kondisional (switch-case).

  • Smooth Navigation: Fungsi Maps memastikan posisi gulir (scroll) kembali ke atas setiap kali berpindah halaman.

  • Persistent Header/Footer: Navigasi utama dan informasi kaki tetap ada di semua halaman untuk konsistensi UX.

3. Komponen dan Halaman Utama

HomeView (Beranda)

  • Fungsi: Menampilkan banner visual (Hero Section) dan produk unggulan.

  • Logika: Memfilter produk dari database yang memiliki status isNew: TRUE.

ShopView (Katalog)

  • Fungsi: Galeri produk lengkap.

  • Fitur: Menampilkan seluruh koleksi dalam tata letak grid responsif yang menyesuaikan jumlah kolom antara perangkat mobile dan desktop.

ProductDetailView (Detail Produk)

  • Fungsi: Memberikan informasi mendalam mengenai satu produk tertentu.

  • Logika: Mengelola pilihan lokal (ukuran dan warna) sebelum item dimasukkan ke dalam keranjang belanja.

CartView & CheckoutView

  • Fungsi: Manajemen transaksi.

  • Fitur: Kalkulasi total harga secara real-time dan formulir pengumpulan data pelanggan.

AdminDashboard (update_collection.html)

  • Fungsi: Antarmuka manajemen koleksi untuk pemilik butik.

  • Fitur: Konversi gambar ke format Base64 untuk pengunggahan file dan sinkronisasi data teks ke skrip server.

4. Integrasi Data dan Aset

Sinkronisasi Server (google.script.run)

  • Fetching: Fungsi getProducts dipanggil di dalam useEffect saat aplikasi pertama kali dimuat.

  • Submission: Form admin mengirimkan objek produk dan string gambar ke fungsi addNewProduct.

Pemrosesan Gambar (LH3 Logic)

  • Frontend dilengkapi dengan fungsi formatImageUrl yang secara otomatis mendeteksi ID file Google Drive dan mengubahnya menjadi format lh3.googleusercontent.com. Format ini memastikan gambar dimuat lebih cepat dan melewati pembatasan cross-origin pada Google Sites.

5. Prinsip Desain (UI/UX)

  • Minimalisme: Penggunaan ruang putih (whitespace) yang luas untuk menonjolkan produk.

  • Tipografi: Perpaduan font Serif (Playfair Display) untuk judul agar terkesan mewah dan Sans-Serif (Inter) untuk konten agar mudah dibaca.

  • Responsivitas: Desain sepenuhnya adaptif dari layar ponsel hingga desktop menggunakan breakpoints Tailwind.

6. Penanganan Status (Loading & Error)

  • Loading Screen: Menampilkan animasi spinner saat aplikasi menunggu respon dari Google Apps Script.

  • Error Guard: Menampilkan pesan yang ramah pengguna jika sinkronisasi database gagal, lengkap dengan tombol coba lagi.

Dokumentasi Teknis Backend: The Wardrobe (Google Apps Script)

Dokumen ini merinci logika, struktur, dan fungsi yang terdapat dalam skrip Code.gs yang digunakan untuk mengintegrasikan Google Spreadsheet dan Google Drive sebagai sistem manajemen konten (CMS) untuk butik "The Wardrobe".

1. Ikhtisar Sistem

Skrip ini bertindak sebagai API server-side yang menghubungkan antarmuka web (HTML/React) dengan layanan Google. Skrip menangani penyimpanan data teks ke Spreadsheet dan konversi file gambar dari format Base64 ke penyimpanan permanen di Google Drive.

2. Konfigurasi Global

Dua konstanta utama didefinisikan di bagian atas untuk menentukan target penyimpanan:

  • SPREADSHEET_ID: Identitas unik dari Google Spreadsheet yang digunakan sebagai database.

  • FOLDER_ID: Identitas unik dari folder Google Drive tempat foto koleksi akan disimpan.

3. Rincian Fungsi

addNewProduct(product, imageBase64)

Fungsi utama untuk menyimpan data koleksi baru yang dikirim dari formulir admin.

  • Input:

    • product: Objek JavaScript berisi detail baju (id, nama, harga, dll).

    • imageBase64: String gambar dalam format Base64 yang diambil dari input file.

  • Logika:

    1. Membuka Spreadsheet berdasarkan ID.

    2. Memanggil uploadToDrive jika ada data gambar.

    3. Menyusun array baris baru (newRow) sesuai urutan kolom database.

    4. Menambahkan data ke baris terakhir di "Sheet1".

  • Output: Objek status sukses atau melempar error jika gagal.

uploadToDrive(base64Data, productName)

Mengurus proses teknis pengunggahan gambar ke Google Drive.

  • Input: Data Base64 dan Nama Produk untuk penamaan file.

  • Logika:

    1. Mengakses folder tujuan menggunakan FOLDER_ID.

    2. Melakukan dekode Base64 menjadi Blob (Binary Large Object).

    3. Membuat file baru dengan timestamp agar nama file unik.

    4. Penanganan Izin: Mencoba menyetel file agar bisa dilihat oleh publik (setSharing). Jika gagal karena batasan akun, proses tetap dilanjutkan tanpa menghentikan penyimpanan ke Spreadsheet.

  • Output: URL gambar dalam format lh3.googleusercontent.com menggunakan ID file.

getProducts()

Fungsi untuk mengambil seluruh data produk untuk ditampilkan di sisi pelanggan.

  • Logika:

    1. Membaca seluruh rentang data di Spreadsheet.

    2. Memetakan baris menjadi objek JSON berdasarkan header kolom.

    3. Memproses kolom images, colors, dan sizes menjadi array dengan pemisah titik koma (;).

  • Output: Array of Objects berisi daftar produk.

debug_forceAuth()

Fungsi pembantu (utility) yang digunakan hanya di editor skrip untuk memicu dialog izin akses Google Drive secara manual jika terjadi error "Akses Ditolak".

4. Strategi Penanganan Kesalahan (Error Handling)

Skrip ini menggunakan blok try-catch di setiap fungsi utama untuk:

  1. Memberikan pesan error yang informatif ke antarmuka pengguna.

  2. Mencegah kegagalan total sistem jika salah satu proses non-kritis (seperti pengaturan izin publik pada file) gagal dilakukan secara otomatis.

5. Prasyarat Integrasi

Agar skrip ini berjalan lancar:

  • Nama Tab: Harus bernama "Sheet1".

  • Header Baris 1: Harus berisi id, name, price, category, description, images, colors, sizes, isNew.

  • Izin Folder: Folder di Drive disarankan sudah disetel ke "Anyone with the link" secara manual untuk keamanan tambahan.




Kode.gs

function doGet(e) {
  var page = e.parameter.page || 'wardrobe_embedded';
  return HtmlService.createTemplateFromFile(page)
      .evaluate()
      .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
      .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

/**
 * PENTING: CARA MENGATASI ERROR IZIN (AUTHORIZATION) SECARA TOTAL
 * * Jika error "Exception: Akses ditolak: DriveApp" muncul:
 * * 1. Ini sering disebabkan karena perintah setSharing (pembagian publik) diblokir.
 * * 2. Pastikan Anda tidak login di banyak akun Google secara bersamaan di satu browser.
 * * 3. Pastikan folder tujuan di Drive sudah disetel share-nya ke "Anyone with the link" secara manual.
 */

/**
 * KONFIGURASI DATABASE
 */
const SPREADSHEET_ID = '1ixFMMNRcV5k1zYw3a0gcZN4UEQ2kV4BjqYRvhP9uD-c';
const FOLDER_ID = '1s9QXPrVaezDg6YAv3uzysm4Wfkpgg5y8';

/**
 * FUNGSI DEBUG UNTUK MEMAKSA OTORISASI DRIVE
 */
function debug_forceAuth() {
  try {
    const folder = DriveApp.getFolderById(FOLDER_ID);
    const testFile = folder.createFile("test_auth.txt", "Tes Otorisasi Berhasil");
    testFile.setTrashed(true);
    console.log("Otorisasi Drive berhasil dideteksi dan diberikan.");
  } catch (e) {
    console.error("Gagal melakukan debug otorisasi: " + e.toString());
  }
}

/**
 * FUNGSI UNTUK MENERIMA DATA DARI CLIENT
 */
function addNewProduct(product, imageBase64) {
  try {
    const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheet = ss.getSheetByName("Sheet1");
   
    let imageUrl = "";
   
    // 1. Jika ada gambar, unggah ke Google Drive
    if (imageBase64) {
      imageUrl = uploadToDrive(imageBase64, product.name);
    }
   
    // 2. Susun data untuk baris baru
    const newRow = [
      product.id,
      product.name,
      product.price,
      product.category,
      product.description,
      imageUrl,
      product.colors,
      product.sizes,
      product.isNew ? "TRUE" : "FALSE"
    ];
   
    // 3. Tambahkan ke baris terakhir Spreadsheet
    sheet.appendRow(newRow);
   
    return { status: "success", message: "Koleksi berhasil disimpan ke Spreadsheet." };
   
  } catch (err) {
    throw new Error("Gagal menyimpan data ke Spreadsheet: " + err.toString());
  }
}

/**
 * FUNGSI UNTUK UNGGAH FILE KE DRIVE
 */
function uploadToDrive(base64Data, productName) {
  try {
    const folder = DriveApp.getFolderById(FOLDER_ID);
   
    // Bersihkan data base64
    const contentType = base64Data.substring(5, base64Data.indexOf(';'));
    const bytes = Utilities.base64Decode(base64Data.split(',')[1]);
   
    // Buat file baru
    const fileName = productName + "_" + new Date().getTime();
    const file = folder.createFile(Utilities.newBlob(bytes, contentType, fileName));
   
    // Ambil ID file terlebih dahulu
    const fileId = file.getId();

    // 2. Coba set izin agar publik (Tindakan ini sering memicu 'Akses Ditolak')
    // Kita bungkus dalam try-catch agar jika gagal, data tetap bisa masuk ke Spreadsheet
    try {
      file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
    } catch (sharingError) {
      console.warn("Peringatan: Gagal menyetel izin publik secara otomatis. Sila setel folder secara manual. " + sharingError.toString());
      // Lanjutkan saja, jangan lempar error agar Spreadsheet tetap terupdate
    }
   
    // Kembalikan URL dalam format lh3.googleusercontent.com
    return "https://lh3.googleusercontent.com/d/" + fileId;
  } catch (err) {
    throw new Error("Gagal proses unggah foto: " + err.toString());
  }
}

/**
 * FUNGSI UNTUK MENGAMBIL DATA
 */
function getProducts() {
  try {
    const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheet = ss.getSheetByName("Sheet1");
    const data = sheet.getDataRange().getValues();
   
    if (data.length < 1) return [];
   
    const headers = data[0].map(h => h.toString().toLowerCase());
   
    return data.slice(1).map(row => {
      let obj = {};
      headers.forEach((h, i) => {
        let val = row[i];
        if (['images', 'colors', 'sizes'].includes(h)) {
          obj[h] = val ? val.toString().split(';').map(v => v.trim()) : [];
        } else {
          obj[h] = val;
        }
      });
      return obj;
    });
  } catch (err) {
    console.error("Gagal mengambil data produk: " + err.toString());
    return [];
  }
}

Wardrobe.html

<!DOCTYPE html>
<html lang="ms">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>The Wardrobe Boutique</title>
    <!-- React & Babel Standalone -->
    <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <!-- Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Inter:wght@300;400;500;600;700&display=swap');
       
        :root {
            --font-serif: 'Playfair Display', serif;
            --font-sans: 'Inter', sans-serif;
        }

        body {
            font-family: var(--font-sans);
        }

        .font-serif {
            font-family: var(--font-serif);
        }
    </style>
</head>
<body class="bg-white text-zinc-900">
    <div id="root"></div>

    <script type="text/babel">
        const { useState, useEffect, useMemo } = React;

        /**
         * FUNGSI PEMFORMATAN IMEJ
         * Menukarkan ID Google Drive kepada format lh3.googleusercontent.com
         */
        const formatImageUrl = (url) => {
            if (!url) return 'https://placehold.co/600x900?text=Tiada+Imej';
            // Jika ia adalah ID atau URL Drive, tukar kepada format LH3
            const driveIdMatch = url.match(/(?:\/d\/|id=)([\w-]+)/);
            if (driveIdMatch && driveIdMatch[1]) {
                return `https://lh3.googleusercontent.com/d/${driveIdMatch[1]}`;
            }
            return url;
        };

        // Komponen Ikon Ringkas
        const Icon = ({ name, size = 20, className = "" }) => {
            const icons = {
                shoppingBag: (
                    <>
                        <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
                        <path d="M3 6h18"/>
                        <path d="M16 10a4 4 0 0 1-8 0"/>
                    </>
                ),
                user: (
                    <>
                        <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
                        <circle cx="12" cy="7" r="4"/>
                    </>
                ),
                search: (
                    <>
                        <circle cx="11" cy="11" r="8"/>
                        <path d="m21 21-4.3-4.3"/>
                    </>
                ),
                arrowLeft: (
                    <>
                        <path d="m12 19-7-7 7-7"/>
                        <path d="M19 12H5"/>
                    </>
                ),
                x: (
                    <>
                        <path d="M18 6 6 18"/>
                        <path d="m6 6 12 12"/>
                    </>
                ),
                chevronRight: <path d="m9 18 6-6-6-6"/>,
                checkCircle: (
                    <>
                        <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
                        <polyline points="22 4 12 14.01 9 11.01"/>
                    </>
                ),
                package: (
                    <>
                        <path d="M16.5 9.4 7.5 4.21"/>
                        <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
                        <polyline points="3.29 7 12 12 20.71 7"/>
                        <line x1="12" y1="22" x2="12" y2="12"/>
                    </>
                ),
                loader: (
                    <>
                        <path d="M12 2v4"/>
                        <path d="m16.2 7.8 2.9-2.9"/>
                        <path d="M18 12h4"/>
                        <path d="m16.2 16.2 2.9 2.9"/>
                        <path d="M12 18v4"/>
                        <path d="m4.9 19.1 2.9-2.9"/>
                        <path d="M2 12h4"/>
                        <path d="m4.9 4.9 2.9 2.9"/>
                    </>
                )
            };

            return (
                <svg
                    xmlns="http://www.w3.org/2000/svg"
                    width={size}
                    height={size}
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    className={className}
                >
                    {icons[name]}
                </svg>
            );
        };

        function App() {
            const [view, setView] = useState('home');
            const [products, setProducts] = useState([]);
            const [cart, setCart] = useState([]);
            const [selectedProduct, setSelectedProduct] = useState(null);
            const [isLoading, setIsLoading] = useState(true);
            const [error, setError] = useState(null);

            // Mengambil data menggunakan google.script.run
            useEffect(() => {
                const fetchData = () => {
                    if (typeof google !== 'undefined' && google.script && google.script.run) {
                        google.script.run
                            .withSuccessHandler((data) => {
                                // Proses data dari server-side getProducts()
                                setProducts(data);
                                setIsLoading(false);
                            })
                            .withFailureHandler((err) => {
                                console.error(err);
                                setError("Gagal memuatkan data daripada Skrip Google.");
                                setIsLoading(false);
                            })
                            .getProducts(); // Pastikan fungsi ini wujud dalam Code.gs
                    } else {
                        // Fallback untuk tujuan pembangunan/preview
                        setError("Sila jalankan aplikasi ini dalam persekitaran Google Apps Script.");
                        setIsLoading(false);
                    }
                };
                fetchData();
            }, []);

            const navigate = (to, data = null) => {
                if (data) setSelectedProduct(data);
                setView(to);
                window.scrollTo(0, 0);
            };

            const addToCart = (product, config) => {
                setCart([...cart, { ...product, ...config, cartId: Math.random().toString(36).substr(2, 9) }]);
                navigate('cart');
            };

            if (isLoading) return (
                <div className="h-screen flex flex-col items-center justify-center text-zinc-400">
                    <Icon name="loader" className="animate-spin mb-4" size={32} />
                    <p className="text-[10px] uppercase tracking-widest font-bold text-zinc-500">Menyediakan Butik...</p>
                </div>
            );

            if (error) return (
                <div className="h-screen flex flex-col items-center justify-center text-center px-6">
                    <p className="text-red-500 mb-4 font-serif">{error}</p>
                    <button onClick={() => window.location.reload()} className="text-[10px] uppercase font-bold border-b border-black">Cuba Lagi</button>
                </div>
            );

            return (
                <div className="min-h-screen">
                    {/* Navigasi Utama */}
                    <nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-zinc-100 px-6 py-4 flex items-center justify-between">
                        <div className="flex items-center space-x-8">
                            <button onClick={() => navigate('shop')} className="hidden md:block text-[10px] uppercase tracking-[0.2em] font-bold hover:text-amber-600 transition-colors">Koleksi</button>
                            <button onClick={() => navigate('home')} className="hidden md:block text-[10px] uppercase tracking-[0.2em] font-bold hover:text-amber-600 transition-colors">Jurnal</button>
                        </div>
                        <button onClick={() => navigate('home')} className="text-2xl font-serif tracking-tighter uppercase">THE WARDROBE</button>
                        <div className="flex items-center space-x-6">
                            <button onClick={() => navigate('account')} className="hover:text-amber-600"><Icon name="user" /></button>
                            <button onClick={() => navigate('cart')} className="relative hover:text-amber-600">
                                <Icon name="shoppingBag" />
                                {cart.length > 0 && <span className="absolute -top-1 -right-1 bg-zinc-900 text-white text-[8px] w-3.5 h-3.5 rounded-full flex items-center justify-center font-bold">{cart.length}</span>}
                            </button>
                        </div>
                    </nav>

                    <main>
                        {view === 'home' && <HomeView products={products} onNavigate={navigate} />}
                        {view === 'shop' && <ShopView products={products} onNavigate={navigate} />}
                        {view === 'product' && <ProductDetailView product={selectedProduct} onAddToCart={addToCart} onBack={() => navigate('shop')} />}
                        {view === 'cart' && <CartView cart={cart} onRemove={(id) => setCart(cart.filter(i => i.cartId !== id))} onCheckout={() => navigate('checkout')} />}
                        {view === 'checkout' && <CheckoutView total={cart.reduce((a, b) => a + b.price, 0)} onOrder={() => {setCart([]); setView('success');}} onBack={() => navigate('cart')} />}
                        {view === 'success' && <SuccessView onNavigate={navigate} />}
                        {view === 'account' && <AccountView />}
                    </main>

                    {/* Pengaki Halaman */}
                    <footer className="bg-zinc-50 border-t border-zinc-100 py-20 px-8 mt-20">
                        <div className="max-w-7xl auto grid grid-cols-1 md:grid-cols-4 gap-12 mx-auto">
                            <div>
                                <h3 className="font-serif text-xl mb-6">THE WARDROBE</h3>
                                <p className="text-sm text-zinc-500 leading-relaxed italic">Keanggunan abadi dalam setiap jahitan.</p>
                            </div>
                            <div>
                                <h4 className="text-[10px] uppercase tracking-widest font-bold mb-6 text-zinc-400">Maklumat</h4>
                                <ul className="text-sm text-zinc-600 space-y-3">
                                    <li>Jejak Pesanan</li>
                                    <li>Panduan Saiz</li>
                                    <li>Mengenai Kami</li>
                                </ul>
                            </div>
                            <div className="md:col-span-2">
                                <h4 className="text-[10px] uppercase tracking-widest font-bold mb-6 text-zinc-400">Hab Skrip Google</h4>
                                <p className="text-xs text-zinc-400">Laman web ini dihubungkan secara terus melalui Google Apps Script.</p>
                            </div>
                        </div>
                    </footer>
                </div>
            );
        }

        // Paparan Utama
        function HomeView({ products, onNavigate }) {
            const featured = products.filter(p => String(p.isnew).toUpperCase() === 'TRUE').slice(0, 4);
            return (
                <div className="animate-in fade-in duration-700">
                    <div className="relative h-[80vh] bg-zinc-100 overflow-hidden">
                        <img src="https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&q=80&w=2000" className="w-full h-full object-cover opacity-90" alt="Imej Utama" />
                        <div className="absolute inset-0 flex flex-col items-center justify-center text-center px-4">
                            <span className="uppercase tracking-[0.3em] text-[10px] mb-6 font-bold">Koleksi Musim Bunga 2024</span>
                            <h1 className="text-5xl md:text-7xl font-serif mb-10">Kemurnian Sutera</h1>
                            <button onClick={() => onNavigate('shop')} className="bg-zinc-900 text-white px-10 py-4 text-[10px] uppercase tracking-[0.2em] font-bold hover:bg-amber-600 transition-all">Terokai Koleksi</button>
                        </div>
                    </div>
                    <div className="max-w-7xl mx-auto px-6 py-24">
                        <h2 className="text-3xl font-serif mb-12 text-center">Ketibaan Baharu</h2>
                        <div className="grid grid-cols-1 md:grid-cols-4 gap-8">
                            {featured.map(p => <ProductCard key={p.id} product={p} onClick={() => onNavigate('product', p)} />)}
                        </div>
                    </div>
                </div>
            );
        }

        function ShopView({ products, onNavigate }) {
            return (
                <div className="max-w-7xl mx-auto px-6 py-20 animate-in slide-in-from-bottom-4">
                    <h1 className="text-4xl font-serif mb-16 text-center">Katalog Lengkap</h1>
                    <div className="grid grid-cols-1 md:grid-cols-4 gap-x-8 gap-y-16">
                        {products.map(p => <ProductCard key={p.id} product={p} onClick={() => onNavigate('product', p)} />)}
                    </div>
                </div>
            );
        }

        function ProductCard({ product, onClick }) {
            const imgUrl = formatImageUrl(product.images && product.images.length > 0 ? product.images[0] : null);
            return (
                <div onClick={onClick} className="group cursor-pointer">
                    <div className="aspect-[2/3] overflow-hidden bg-zinc-50 mb-4 relative">
                        <img src={imgUrl} className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" alt={product.name} />
                        {String(product.isnew).toUpperCase() === 'TRUE' && <span className="absolute top-4 left-4 bg-white text-zinc-900 px-3 py-1 text-[8px] uppercase font-bold tracking-widest">Baharu</span>}
                    </div>
                    <h3 className="text-[10px] font-bold uppercase tracking-widest mb-1">{product.name}</h3>
                    <p className="font-serif text-zinc-500">RM {product.price}</p>
                </div>
            );
        }

        function ProductDetailView({ product, onAddToCart, onBack }) {
            if (!product) return null;
            const [size, setSize] = useState(product.sizes?.[0] || "");
            const [color, setColor] = useState(product.colors?.[0] || "");
            const imgUrl = formatImageUrl(product.images && product.images.length > 0 ? product.images[0] : null);

            return (
                <div className="max-w-7xl mx-auto px-6 py-12">
                    <button onClick={onBack} className="flex items-center text-[10px] uppercase font-bold mb-12 hover:text-amber-600">
                        <Icon name="arrowLeft" size={14} className="mr-2" /> Kembali ke Katalog
                    </button>
                    <div className="grid grid-cols-1 md:grid-cols-2 gap-16">
                        <div className="aspect-[4/5] bg-zinc-50 overflow-hidden">
                            <img src={imgUrl} className="w-full h-full object-cover" alt={product.name} />
                        </div>
                        <div>
                            <span className="text-amber-700 text-[10px] uppercase font-bold tracking-widest">{product.category}</span>
                            <h1 className="text-4xl font-serif mt-2 mb-6">{product.name}</h1>
                            <p className="text-2xl font-serif mb-10 text-zinc-900">RM {product.price}</p>
                            <div className="space-y-8 mb-12 border-t border-zinc-100 pt-8">
                                {product.colors?.length > 0 && (
                                    <div>
                                        <p className="text-[10px] uppercase font-bold text-zinc-400 mb-4 tracking-widest">Pilih Warna</p>
                                        <div className="flex flex-wrap gap-2">
                                            {product.colors.map(c => (
                                                <button key={c} onClick={() => setColor(c)} className={`px-4 py-2 border text-[10px] uppercase font-bold ${color === c ? 'bg-zinc-900 text-white' : 'hover:border-zinc-900'}`}>{c}</button>
                                            ))}
                                        </div>
                                    </div>
                                )}
                                {product.sizes?.length > 0 && (
                                    <div>
                                        <p className="text-[10px] uppercase font-bold text-zinc-400 mb-4 tracking-widest">Pilih Saiz</p>
                                        <div className="flex flex-wrap gap-2">
                                            {product.sizes.map(s => (
                                                <button key={s} onClick={() => setSize(s)} className={`w-10 h-10 border flex items-center justify-center text-[10px] font-bold ${size === s ? 'bg-zinc-900 text-white' : 'hover:border-zinc-900'}`}>{s}</button>
                                            ))}
                                        </div>
                                    </div>
                                )}
                            </div>
                            <button onClick={() => onAddToCart(product, { size, color })} className="w-full bg-zinc-900 text-white py-5 text-[10px] uppercase font-bold tracking-widest hover:bg-amber-600 transition-colors">Tambah ke Beg</button>
                            <div className="mt-12 text-sm text-zinc-500 leading-relaxed whitespace-pre-line">{product.description}</div>
                        </div>
                    </div>
                </div>
            );
        }

        function CartView({ cart, onRemove, onCheckout }) {
            const total = cart.reduce((acc, i) => acc + i.price, 0);
            return (
                <div className="max-w-xl mx-auto px-6 py-20">
                    <h1 className="text-3xl font-serif mb-12 text-center">Beg Belanja</h1>
                    {cart.length === 0 ? <p className="text-center italic text-zinc-400 font-serif">Beg belanja anda kosong.</p> : (
                        <div className="space-y-8">
                            {cart.map(item => (
                                <div key={item.cartId} className="flex gap-6 pb-8 border-b border-zinc-50">
                                    <img src={formatImageUrl(item.images?.[0])} className="w-20 h-28 object-cover bg-zinc-100" alt={item.name} />
                                    <div className="flex-1">
                                        <div className="flex justify-between items-start">
                                            <h3 className="font-serif">{item.name}</h3>
                                            <button onClick={() => onRemove(item.cartId)} className="text-zinc-400 hover:text-black"><Icon name="x" size={14} /></button>
                                        </div>
                                        <p className="text-[10px] text-zinc-400 uppercase mt-1">{item.size} / {item.color}</p>
                                        <p className="mt-4 font-serif text-amber-800">RM {item.price}</p>
                                    </div>
                                </div>
                            ))}
                            <div className="pt-8 flex justify-between items-center text-xl font-serif border-t border-zinc-100">
                                <span className="text-sm font-sans uppercase font-bold tracking-widest text-zinc-400">Jumlah Keseluruhan</span>
                                <span>RM {total}</span>
                            </div>
                            <button onClick={onCheckout} className="w-full bg-zinc-900 text-white py-4 text-[10px] uppercase font-bold tracking-widest hover:bg-zinc-800 transition-colors">Daftar Keluar</button>
                        </div>
                    )}
                </div>
            );
        }

        function CheckoutView({ total, onOrder, onBack }) {
            return (
                <div className="max-w-4xl mx-auto px-6 py-20">
                    <div className="grid grid-cols-1 md:grid-cols-2 gap-16">
                        <div className="space-y-8">
                            <h2 className="text-2xl font-serif text-zinc-900">Alamat Penghantaran</h2>
                            <div className="space-y-4">
                                <input placeholder="Nama Penuh" className="w-full p-4 border border-zinc-100 bg-zinc-50 outline-none text-sm focus:border-black transition-all" />
                                <input placeholder="Emel" className="w-full p-4 border border-zinc-100 bg-zinc-50 outline-none text-sm focus:border-black transition-all" />
                                <input placeholder="Alamat Lengkap" className="w-full p-4 border border-zinc-100 bg-zinc-50 outline-none text-sm focus:border-black transition-all" />
                            </div>
                            <button onClick={onOrder} className="w-full bg-zinc-900 text-white py-4 text-[10px] uppercase font-bold tracking-widest hover:bg-amber-600 transition-colors">Selesaikan Pembayaran</button>
                        </div>
                        <div className="bg-zinc-50 p-8 h-fit">
                            <h2 className="text-xl font-serif mb-6">Ringkasan Pesanan</h2>
                            <div className="flex justify-between text-sm mb-4"><span className="text-zinc-400">Jumlah Kecil</span><span>RM {total}</span></div>
                            <div className="flex justify-between text-sm mb-4 font-bold border-t border-zinc-200 pt-4"><span className="text-zinc-400 uppercase text-[10px]">Jumlah Akhir</span><span>RM {total}</span></div>
                        </div>
                    </div>
                </div>
            );
        }

        function SuccessView({ onNavigate }) {
            return (
                <div className="max-w-md mx-auto text-center py-32 px-6 animate-in zoom-in duration-500">
                    <Icon name="checkCircle" className="mx-auto text-emerald-500 mb-6" size={48} />
                    <h1 className="text-3xl font-serif mb-4">Terima Kasih</h1>
                    <p className="text-sm text-zinc-500 mb-10">Pesanan anda telah berjaya diterima dan akan diproses secepat mungkin.</p>
                    <button onClick={() => onNavigate('home')} className="w-full py-4 border border-zinc-900 text-[10px] uppercase font-bold tracking-widest hover:bg-zinc-900 hover:text-white transition-all">Kembali ke Laman Utama</button>
                </div>
            );
        }

        function AccountView() {
            return (
                <div className="max-w-4xl mx-auto px-6 py-20">
                    <h1 className="text-4xl font-serif mb-12">Akaun Saya</h1>
                    <div className="p-12 border border-zinc-100 text-center italic text-zinc-400 font-serif">Tiada rekod pesanan buat masa ini.</div>
                </div>
            );
        }

        const root = ReactDOM.createRoot(document.getElementById("root"));
        root.render(<App />);
    </script>
</body>
</html>


update_collection.html
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin - Update Koleksi The Wardrobe</title>
    <!-- React & Babel Standalone -->
    <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <!-- Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@300;400;600&display=swap');
       
        :root {
            --font-serif: 'Playfair Display', serif;
            --font-sans: 'Inter', sans-serif;
        }

        body {
            font-family: var(--font-sans);
            background-color: #fcfcfc;
        }

        .font-serif {
            font-family: var(--font-serif);
        }
    </style>
</head>
<body class="text-zinc-900">
    <div id="root"></div>

    <script type="text/babel">
        const { useState, useEffect } = React;

        // Komponen Utama Dashboard Admin
        function AdminDashboard() {
            const [products, setProducts] = useState([]);
            const [loading, setLoading] = useState(false);
            const [status, setStatus] = useState({ type: '', message: '' });
            const [formData, setFormData] = useState({
                id: '',
                name: '',
                price: '',
                category: 'Dresses',
                description: '',
                colors: '',
                sizes: '',
                isNew: false
            });
            const [imageFile, setImageFile] = useState(null);

            // Fungsi untuk menangani input form
            const handleInputChange = (e) => {
                const { name, value, type, checked } = e.target;
                setFormData(prev => ({
                    ...prev,
                    [name]: type === 'checkbox' ? checked : value
                }));
            };

            // Fungsi untuk konversi file ke base64
            const fileToBase64 = (file) => new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.readAsDataURL(file);
                reader.onload = () => resolve(reader.result);
                reader.onerror = error => reject(error);
            });

            // Fungsi Submit Form
            const handleSubmit = async (e) => {
                e.preventDefault();
                setLoading(true);
                setStatus({ type: 'info', message: 'Sedang memproses data...' });

                try {
                    let base64Image = "";
                    if (imageFile) {
                        base64Image = await fileToBase64(imageFile);
                    }

                    // Memanggil fungsi server-side di Code.gs
                    google.script.run
                        .withSuccessHandler((response) => {
                            setLoading(false);
                            setStatus({ type: 'success', message: 'Koleksi berhasil diperbarui!' });
                            // Reset form sederhana
                            setFormData({
                                id: '', name: '', price: '', category: 'Dresses',
                                description: '', colors: '', sizes: '', isNew: false
                            });
                            setImageFile(null);
                        })
                        .withFailureHandler((err) => {
                            setLoading(false);
                            setStatus({ type: 'error', message: 'Gagal: ' + err.message });
                        })
                        .addNewProduct(formData, base64Image); // Fungsi ini harus ada di Code.gs
                } catch (err) {
                    setLoading(false);
                    setStatus({ type: 'error', message: 'Terjadi kesalahan sistem.' });
                }
            };

            return (
                <div className="max-w-4xl mx-auto px-6 py-12">
                    <header className="mb-12 border-b border-zinc-100 pb-8 flex justify-between items-end">
                        <div>
                            <h1 className="text-3xl font-serif mb-2">Manajemen Koleksi</h1>
                            <p className="text-zinc-500 text-sm italic">Panel Admin The Wardrobe Boutique</p>
                        </div>
                        <div className="text-[10px] uppercase tracking-widest font-bold text-zinc-400">
                            Sinkronisasi Real-time
                        </div>
                    </header>

                    {status.message && (
                        <div className={`mb-8 p-4 text-xs font-bold uppercase tracking-widest flex items-center justify-between ${
                            status.type === 'success' ? 'bg-emerald-50 text-emerald-700' :
                            status.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700'
                        }`}>
                            <span>{status.message}</span>
                            <button onClick={() => setStatus({type:'', message:''})} className="opacity-50">Tutup</button>
                        </div>
                    )}

                    <div className="bg-white border border-zinc-100 p-8 shadow-sm">
                        <form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-8">
                            {/* Kolom Kiri */}
                            <div className="space-y-6">
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">ID Produk</label>
                                    <input required name="id" value={formData.id} onChange={handleInputChange} placeholder="Contoh: TW-001" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Nama Produk</label>
                                    <input required name="name" value={formData.name} onChange={handleInputChange} placeholder="Nama Baju" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div className="grid grid-cols-2 gap-4">
                                    <div>
                                        <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Harga (RM)</label>
                                        <input required name="price" type="number" value={formData.price} onChange={handleInputChange} placeholder="0" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                    </div>
                                    <div>
                                        <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Kategori</label>
                                        <select name="category" value={formData.category} onChange={handleInputChange} className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm">
                                            <option value="Dresses">Dresses</option>
                                            <option value="Outerwear">Outerwear</option>
                                            <option value="Tops">Tops</option>
                                            <option value="Accessories">Accessories</option>
                                        </select>
                                    </div>
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Deskripsi Produk</label>
                                    <textarea name="description" value={formData.description} onChange={handleInputChange} rows="4" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm resize-none"></textarea>
                                </div>
                            </div>

                            {/* Kolom Kanan */}
                            <div className="space-y-6">
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Foto Koleksi</label>
                                    <div className="relative border-2 border-dashed border-zinc-100 bg-zinc-50 p-6 text-center cursor-pointer hover:bg-zinc-100 transition-all">
                                        <input type="file" accept="image/*" onChange={(e) => setImageFile(e.target.files[0])} className="absolute inset-0 opacity-0 cursor-pointer" />
                                        <div className="text-zinc-400">
                                            {imageFile ? <span className="text-black font-bold text-xs">{imageFile.name}</span> : <span className="text-xs italic">Pilih atau Seret Foto ke Sini</span>}
                                        </div>
                                    </div>
                                    <p className="mt-2 text-[10px] text-zinc-400 italic">*Foto akan diunggah otomatis ke Google Drive Anda.</p>
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Warna (Pisahkan dengan Titik Koma ';')</label>
                                    <input name="colors" value={formData.colors} onChange={handleInputChange} placeholder="Hitam; Putih; Champagne" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div>
                                    <label className="block text-[10px] uppercase font-bold tracking-widest text-zinc-400 mb-2">Saiz (Pisahkan dengan Titik Koma ';')</label>
                                    <input name="sizes" value={formData.sizes} onChange={handleInputChange} placeholder="S; M; L; XL" className="w-full p-3 border border-zinc-100 bg-zinc-50 outline-none focus:border-black transition-all text-sm" />
                                </div>
                                <div className="flex items-center space-x-3 pt-4">
                                    <input type="checkbox" id="isNew" name="isNew" checked={formData.isNew} onChange={handleInputChange} className="w-4 h-4 accent-black" />
                                    <label htmlFor="isNew" className="text-[10px] uppercase font-bold tracking-widest text-zinc-500">Tandai sebagai Koleksi Baharu</label>
                                </div>
                            </div>

                            <div className="md:col-span-2 pt-8">
                                <button
                                    disabled={loading}
                                    type="submit"
                                    className={`w-full py-4 text-[10px] uppercase font-bold tracking-[0.2em] transition-all flex items-center justify-center space-x-2 ${
                                        loading ? 'bg-zinc-200 text-zinc-400' : 'bg-black text-white hover:bg-zinc-800'
                                    }`}
                                >
                                    {loading ? 'Sedang Memproses...' : 'Simpan ke Koleksi'}
                                </button>
                            </div>
                        </form>
                    </div>

                    <footer className="mt-20 text-center">
                        <button onClick={() => window.open('https://docs.google.com/spreadsheets/d/e/2PACX-1vQscRV1rweAv033WKZJLSSbdCnJt1yEWgSB5dQhu69Uf7m-0SEr8f-sdEwHlQWPNrQmXRowp039v46W/pub?gid=0&single=true&output=csv')} className="text-[10px] uppercase font-bold border-b border-black pb-1 opacity-50 hover:opacity-100 transition-opacity">
                            Buka Spreadsheet Database
                        </button>
                    </footer>
                </div>
            );
        }

        const root = ReactDOM.createRoot(document.getElementById("root"));
        root.render(<AdminDashboard />);
    </script>
</body>
</html>


Apps Script - Dashboard CRUD

  https://docs.google.com/spreadsheets/d/1mOlgs49uHqfoGfSFstd__wJo02FbRB5ofQWzcvOq6tw/edit?gid=0#gid=0 https://script.google.com/u/0/home/pr...