Rabu, 27 Mei 2026

Talenta Hub

 



PROMPT SPESIFIKASI PROYEK: RUANGKARIR PORTAL HUB

Bertindaklah sebagai Senior Google Apps Script Developer dan Front-End Architect (Tailwind & Alpine.js/Vanilla JS Expert). Saya sedang mengembangkan aplikasi web RuangKarir, yaitu platform portal/marketplace modern yang menghubungkan alumni vokasi (SMK/Politeknik), freelancer umum, dan kreator digital dengan mitra industri/perusahaan nasional.

Aplikasi ini menggunakan basis data Google Spreadsheet (dengan nama spreadsheet verbatim: ruangkarir) dan dideploy menggunakan metode Bound Script.

I. ARSITEKTUR DATABASE SPREADSHEET (ruangkarir)

Terdapat 5 sheet dengan skema relasional yang bersih:

  1. tbl_talents: talent_id (PK, format TLN-YYYYMMDD-RAND), nama_lengkap, email, role_keahlian, kategori (Teknologi|Desain|Kreator|Penulis), badge (Alumni Vokasi|Kreator Digital|Freelancer), rating, proyek_selesai, rate_harga, skills (comma-separated), bio, avatar_url, created_at.

  2. tbl_companies: company_id (PK, format CMP-YYYYMMDD-RAND), nama_perusahaan, email, industri, lokasi, logo_url, created_at.

  3. tbl_jobs: job_id (PK, format JOB-YYYYMMDD-RAND), company_id (FK), judul_pekerjaan, kategori, budget, lokasi, persyaratan, status (Aktif|Selesai|Draf), created_at.

  4. tbl_applications: application_id (PK), job_id (FK), talent_id (FK), company_id (FK), tipe_aksi, status, applied_at.

  5. tbl_messages: message_id (PK), sender_id (FK), receiver_id (FK), isi_pesan, sent_at.

II. STRUKTUR BERKAS APLIKASI (BOUND SCRIPT)

Sistem menggunakan metode modularisasi file terpisah di Apps Script menggunakan fungsi include() sisi server:

1. Code.gs (Backend Controller)

  • Fungsi doGet() untuk mengevaluasi template Index.html dengan XFrameOptions mode ALLOWALL.

  • Fungsi include(filename) untuk menyisipkan HTML/CSS/JS pendukung.

  • Fungsi normalizeData(headers, rowData): Melakukan normalisasi data ketat di sisi server (mengonversi objek Date menjadi string ISO/format standar, memvalidasi angka integer/float seperti rating & proyek_selesai, serta memastikan nilai Boolean aman sebelum dikonversi menjadi JSON).

  • Fungsi CRUD Relasional:

    • getDashboardData(): Memuat dan menormalisasi seluruh data dari 5 sheet.

    • addTalentServer(talentData): Pendaftaran talent baru dengan generate ID acak.

    • addOrUpdateCompanyServer(companyData): Menyimpan atau mengedit profil perusahaan berdasarkan keberadaan company_id.

    • addOrUpdateJobServer(jobData): Menyimpan atau mengedit lowongan kerja aktif.

    • sendMessageServer(messageData): Mengirimkan riwayat pesan penawaran.

2. Index.html (Main Layout & UI)

  • Mengintegrasikan Tailwind CSS, Chart.js, Google Fonts (Poppins, Inter), dan Lucide Icons.

  • Menampilkan panel Navigasi Tab dinamis (Direktori Talent, Mitra Perusahaan, Lowongan Kerja).

  • Menyediakan kartu statistik visual (Total Talent, Loker, Mitra, Aksi Rekrutmen).

  • Menyisipkan modal formulir modular: modalTalent, modalMessage, dan menyertakan berkas company.html serta loker.html secara server-side include.

3. css.html (Desain & Tema)

  • Menyediakan visualisasi modern SaaS: border rounded 20px, efek Glassmorphism ringan, transisi mikro-interaksi pada hover.

  • Memiliki kustomisasi penuh dukungan Dark Mode / Light Mode yang merespons preferensi tombol toggle di header.

4. JavaScript.html (Client-Side Controller)

  • Mengelola state global aplikasi (appState) secara dinamis.

  • Melakukan komunikasi AJAX asinkronus ke server menggunakan google.script.run yang aman dengan visualisasi state pemuatan (loading spin).

  • Fitur pencarian live-filtering pada direktori tab aktif tanpa perlu memuat ulang halaman (real-time client-side search).

  • Menggambar visualisasi grafik Chart.js (Donut Chart & Bar Chart) secara resilient: melacak referensi grafik global, menghancurkan instansi chart lama sebelum render ulang, dan menampilkan fallback otomatis jika basis data kosong.

5. company.html (Modul Perusahaan)

  • Form dialog modular untuk pendaftaran atau pembaruan profil mitra industri secara inline tanpa reload.

6. loker.html (Modul Lowongan Kerja)

  • Form dialog lowongan kerja aktif yang secara dinamis memetakan dropdown instansi penerbit langsung dari basis data tbl_companies terbaru.

III. INSTRUKSI PENGEMBANGAN DAN PEMBARUAN

Jika saya meminta Anda untuk melakukan modifikasi, penambahan fitur, debugging, atau pembuatan fitur baru, pastikan Anda:

  1. Tetap mematuhi struktur pemisahan berkas di atas (Code.gs, Index.html, JavaScript.html, css.html, company.html, loker.html).

  2. Selalu gunakan penanganan kesalahan (error handling) yang tangguh menggunakan blok try...catch baik di sisi server Apps Script maupun di sisi klien JavaScript.

  3. Gunakan kelas Tailwind CSS murni untuk menjaga tampilan antarmuka yang modern, responsif mobile-first, bersih, dan konsisten di setiap mode tema (Gelap/Terang).

  4. Jangan pernah menggunakan fungsi pemblokiran UI bawaan seperti alert() atau confirm(); selalu gunakan elemen notifikasi toast dinamis atau pop-up modal interaktif yang disesuaikan secara estetik.

Harap konfirmasi kesiapan Anda untuk mulai memproses atau mengembangkan fitur-fitur baru berdasarkan spesifikasi sistem di atas!


PROMPT

Bertindaklah sebagai apps script expert. Buat aplikasi web modern bernama RuangKarir yaitu platform marketplace talent digital untuk mempertemukan freelancer, alumni vokasi, kreator digital, dan perusahaan. Buat landing page modern dengan section:


Hero Section


Headline besar


Search talent bar


CTA button


Animated illustration


Trusted company logos

Contoh headline:


“Temukan Pekerjaan Terbaik untuk Masa Depan Karir Anda”


Gunakan desain modern ala startup teknologi seperti Dribbble, Behance, Upwork, Fiverr, dan LinkedIn dengan tampilan clean, premium, futuristik, dan responsive.

STYLE & DESIGN SYSTEM

Gunakan style desain:



Modern SaaS Dashboard


Clean UI


Soft shadow


Glassmorphism ringan


Rounded corner 20px


Gradient modern biru-ungu


Responsive mobile-first


Minimalis profesional


Micro interaction animation


Smooth hover effect


Dark/light mode support

COLOR PALETTE

Primary:



#5B5BD6


#7C4DFF

Secondary:



#EEF2FF


#F8FAFC

Accent:



#22C55E


#F59E0B


#EF4444

Text:



#111827


#6B7280

Background:



#FFFFFF


#F5F7FB

TYPOGRAPHY

Gunakan:



Poppins


Inter


Manrope

Heading:



Bold


Modern startup style 


Deskripsi & Rangkuman Aplikasi Web RuangKarir:

  1. Fitur Navigasi Premium: Pengguna dapat menjelajahi beranda utama, menavigasi langsung ke filter marketplace talent, serta melompat ke demo SaaS Dashboard Hub instan.

  2. Katalog Talent Interaktif:

    • Sistem penyaringan langsung berdasarkan kategori industri (Teknologi, Desain, Kreator, Penulis).

    • Penyaringan berdasarkan status kelulusan/afiliasi (Alumni Vokasi, Kreator Digital, Freelancer).

    • Pencarian real-time yang langsung menyaring nama, keahlian, atau jabatan talent.

  3. Pendaftaran & Pembuatan Loker Instan:

    • Formulir pendaftaran talent baru (Join as Talent) yang responsif. Menambahkan entri baru langsung memperbarui katalog secara real-time pada sesi browser saat ini.

    • Formulir posting pekerjaan baru (Post Job) dengan notifikasi toast modern.

  4. Desain visual: Mengusung skema warna gradasi modern ungu-biru, sudut lengkung premium 20px, efek visual glassmorphic, interaksi hover yang lembut, serta dukungan penuh Dark & Light mode.




Database

Saya akan membuat rancangan arsitektur basis data berbasis Google Spreadsheet untuk platform RuangKarir. Sebagai Apps Script Expert, saya mendesain struktur ini dengan pendekatan relasional (seperti SQL) namun dioptimalkan untuk performa pembacaan dan penulisan baris (row-by-row) menggunakan Google Apps Script.

Rancangan ini terdiri dari 5 tabel (sheet) utama:

  1. tbl_talents (Menyimpan profil lengkap talent, alumni vokasi, dan kreator)

  2. tbl_companies (Menyimpan data industri/perusahaan mitra)

  3. tbl_jobs (Menyimpan lowongan pekerjaan/proyek yang diterbitkan)

  4. tbl_applications (Mencatat lamaran kerja atau aksi perekrutan langsung)

  5. tbl_messages (Menyimpan riwayat pesan/kontak interaktif)

Berikut adalah rancangan lengkap kolom, tipe data, relasi antar-sheet, beserta kode Google Apps Script untuk menginisialisasi spreadsheet Anda secara otomatis.

Arsitektur Database RuangKarir (Google Spreadsheet)

Dokumen ini berisi rancangan struktur data relasional yang diterapkan pada Google Sheets untuk mendukung sistem web app RuangKarir. Setiap tabel dirancang agar mudah dibaca dan dimanipulasi menggunakan metode API Google Apps Script (SpreadsheetApp).

1. Skema Tabel (Sheets)

A. Sheet: tbl_talents

Menyimpan data profil semua talenta digital, alumni vokasi, freelancer, dan kreator.

Kolom (Header)

Tipe Data

Deskripsi / Validasi

Contoh Data

talent_id

String (PK)

ID Unik (Format: TLN-YYYYMMDD-XXXX)

TLN-20260528-0001

nama_lengkap

String

Nama lengkap talent

Ahmad Rafli

email

String

Email aktif (unik)

rafli@vokasi.sch.id

role_keahlian

String

Judul spesialisasi pekerjaan

UI/UX Designer

kategori

String

Pilihan: Teknologi, Desain, Kreator, Penulis

Desain

badge

String

Pilihan: Alumni Vokasi, Kreator Digital, Freelancer

Alumni Vokasi

rating

Number (Float)

Nilai performa (0.0 s.d 5.0)

4.9

proyek_selesai

Number (Int)

Jumlah proyek yang sukses diselesaikan

32

rate_harga

String

Harga jasa / tarif

Rp 150k/jam

skills

String

Comma-separated list (tag keahlian)

Figma, Prototyping, Design System

bio

String

Ringkasan pengalaman atau portofolio

Alumni SMK Vokasi Unggulan bidang RPL.

avatar_url

String

URL foto profil talent

https://placehold.co/150x150

created_at

DateTime

Timestamp pendaftaran

2026-05-28 09:30:15

B. Sheet: tbl_companies

Menyimpan profil industri, instansi, atau UMKM yang mencari talent.

Kolom (Header)

Tipe Data

Deskripsi / Validasi

Contoh Data

company_id

String (PK)

ID Unik (Format: CMP-YYYYMMDD-XXXX)

CMP-20260528-0001

nama_perusahaan

String

Nama instansi / brand

PT Kreatif Abadi

email

String

Email operasional HR/Hiring Manager

hr@kreatifabadi.com

industri

String

Sektor usaha

Creative Agency

lokasi

String

Kota domisili kantor / Remote

Jakarta / Remote

logo_url

String

URL logo perusahaan

https://placehold.co/150x150

created_at

DateTime

Timestamp pendaftaran

2026-05-28 10:00:00

C. Sheet: tbl_jobs

Menyimpan informasi lowongan pekerjaan (loker), proyek lepas, maupun program magang.

Kolom (Header)

Tipe Data

Deskripsi / Validasi

Contoh Data

job_id

String (PK)

ID Unik (Format: JOB-YYYYMMDD-XXXX)

JOB-20260528-0001

company_id

String (FK)

Relasi ke tbl_companies.company_id

CMP-20260528-0001

judul_pekerjaan

String

Nama lowongan

UI/UX Designer (Internship - Vokasi)

kategori

String

Pilihan: Teknologi, Desain, Kreator, Penulis

Desain

budget

String

Rentang anggaran / upah

Rp 5.000.000

lokasi

String

Lokasi kerja

Jakarta / Remote

persyaratan

String

Kualifikasi teknis pelamar

Menguasai Figma, lulusan SMK/Politeknik

status

String

Pilihan: Aktif, Selesai, Draf

Aktif

created_at

DateTime

Timestamp pembuatan lowongan

2026-05-28 11:15:22

D. Sheet: tbl_applications

Mencatat pelamar lowongan kerja atau transaksi rekrutmen langsung dari perusahaan kepada talent.

Kolom (Header)

Tipe Data

Deskripsi / Validasi

Contoh Data

application_id

String (PK)

ID Unik (Format: APP-YYYYMMDD-XXXX)

APP-20260528-0001

job_id

String (FK)

Relasi ke tbl_jobs.job_id (Kosong jika rekrut langsung)

JOB-20260528-0001

talent_id

String (FK)

Relasi ke tbl_talents.talent_id

TLN-20260528-0001

company_id

String (FK)

Relasi ke tbl_companies.company_id

CMP-20260528-0001

tipe_aksi

String

Pilihan: Lamar Lowongan, Rekrut Langsung

Lamar Lowongan

status

String

Pilihan: Pending, Ditinjau, Diterima, Ditolak

Pending

applied_at

DateTime

Timestamp pengajuan

2026-05-28 12:45:00

E. Sheet: tbl_messages

Menyimpan pesan komunikasi masuk dari perusahaan langsung ke kontak personal talent.

Kolom (Header)

Tipe Data

Deskripsi / Validasi

Contoh Data

message_id

String (PK)

ID Unik (Format: MSG-YYYYMMDD-XXXX)

MSG-20260528-0001

sender_id

String (FK)

Pengirim (Bisa company_id atau talent_id)

CMP-20260528-0001

receiver_id

String (FK)

Penerima (Bisa company_id atau talent_id)

TLN-20260528-0001

isi_pesan

String

Teks pesan/diskusi penawaran

Halo Ahmad, kami tertarik dengan portofolio Anda...

sent_at

DateTime

Timestamp pengiriman

2026-05-28 14:02:10

2. Relasi Basis Data (ERD Sederhana)

  • One-to-Many (tbl_companiestbl_jobs): Satu perusahaan dapat memposting banyak lowongan kerja.

  • Many-to-Many (tbl_talentstbl_jobs via tbl_applications): Banyak talent dapat melamar banyak lowongan, menghasilkan entri unik di tabel lamaran.

  • One-to-Many (tbl_talents/tbl_companiestbl_messages): Komunikasi dinamis dua arah yang dicatat berdasarkan ID pengirim dan penerima.

3. Google Apps Script: Auto-Inisialisasi & Pengimporan CSV

Salin kode Apps Script di bawah ini ke editor script spreadsheet Anda (Ekstensi > Apps Script). Fungsi initRuangKarirDatabase() digunakan untuk menyediakan skema tabel bersih, manakala fungsi importUploadedCSVs() bertindak sebagai pemproses otomatis data CSV terunggah dari Google Drive anda.

/**
 * Google Apps Script untuk Inisialisasi Database RuangKarir
 * Author: Apps Script Expert
 */

function initRuangKarirDatabase() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  
  // Definisi struktur tabel
  const databaseSchema = [
    {
      sheetName: "tbl_talents",
      headers: ["talent_id", "nama_lengkap", "email", "role_keahlian", "kategori", "badge", "rating", "proyek_selesai", "rate_harga", "skills", "bio", "avatar_url", "created_at"]
    },
    {
      sheetName: "tbl_companies",
      headers: ["company_id", "nama_perusahaan", "email", "industri", "lokasi", "logo_url", "created_at"]
    },
    {
      sheetName: "tbl_jobs",
      headers: ["job_id", "company_id", "judul_pekerjaan", "kategori", "budget", "lokasi", "persyaratan", "status", "created_at"]
    },
    {
      sheetName: "tbl_applications",
      headers: ["application_id", "job_id", "talent_id", "company_id", "tipe_aksi", "status", "applied_at"]
    },
    {
      sheetName: "tbl_messages",
      headers: ["message_id", "sender_id", "receiver_id", "isi_pesan", "sent_at"]
    }
  ];
  
  // Hapus sheet default 'Sheet1' jika ada & tidak digunakan
  const defaultSheet = ss.getSheetByName("Sheet1");
  
  databaseSchema.forEach(schema => {
    let sheet = ss.getSheetByName(schema.sheetName);
    
    // Jika sheet belum ada, buat baru
    if (!sheet) {
      sheet = ss.insertSheet(schema.sheetName);
    } else {
      sheet.clear(); // Bersihkan jika sudah ada untuk re-inisialisasi
    }
    
    // Tulis header
    const headerRange = sheet.getRange(1, 1, 1, schema.headers.length);
    headerRange.setValues([schema.headers]);
    
    // Desain Header (Tema Premium RuangKarir #5B5BD6)
    headerRange.setBackground("#5B5BD6")
               .setFontColor("#FFFFFF")
               .setFontWeight("bold")
               .setFontFamily("Inter")
               .setHorizontalAlignment("center")
               .setVerticalAlignment("middle");
               
    // Bekukan baris pertama (freeze header)
    sheet.setFrozenRows(1);
    
    // Atur tinggi baris header agar proporsional
    sheet.setRowHeight(1, 35);
    
    // Auto-fit ukuran kolom agar rapi
    sheet.autoResizeColumns(1, schema.headers.length);
  });
  
  if (defaultSheet && ss.getSheets().length > 1) {
    try {
      ss.deleteSheet(defaultSheet);
    } catch(e) {
      // Abaikan jika Sheet1 adalah satu-satunya sheet saat proses
    }
  }
  
  Logger.log("Inisialisasi Database RuangKarir Berhasil!");
}

/**
 * Mengimpor fail-fail CSV terunggah secara automatik dari Google Drive anda.
 * Fail mestilah diletakkan di dalam Google Drive dan dinamakan mengikut padanan berikut.
 */
function importUploadedCSVs() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const csvFiles = [
    { sheetName: "tbl_talents", fileName: "ruangkarir - tbl_talents.csv" },
    { sheetName: "tbl_companies", fileName: "ruangkarir - tbl_companies.csv" },
    { sheetName: "tbl_jobs", fileName: "ruangkarir - tbl_jobs.csv" },
    { sheetName: "tbl_applications", fileName: "ruangkarir - tbl_applications.csv" },
    { sheetName: "tbl_messages", fileName: "ruangkarir - tbl_messages.csv" }
  ];
  
  csvFiles.forEach(item => {
    const files = DriveApp.getFilesByName(item.fileName);
    if (files.hasNext()) {
      const file = files.next();
      const csvContent = file.getBlob().getDataAsString();
      const csvData = Utilities.parseCsv(csvContent);
      const sheet = ss.getSheetByName(item.sheetName);
      
      if (sheet && csvData.length > 1) {
        // Kosongkan data lama di bawah header sebelum menulis semula
        const lastRow = sheet.getLastRow();
        if (lastRow > 1) {
          sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent();
        }
        
        // Asingkan pengepala fail CSV dan ambil baris data sahaja
        const dataToInsert = csvData.slice(1);
        if (dataToInsert.length > 0) {
          sheet.getRange(2, 1, dataToInsert.length, dataToInsert[0].length).setValues(dataToInsert);
          sheet.autoResizeColumns(1, sheet.getLastColumn());
          Logger.log(`Berjaya mengimport ${dataToInsert.length} baris data ke dalam sheet '${item.sheetName}'.`);
        }
      }
    } else {
      Logger.log(`Peringatan: Fail "${item.fileName}" tidak ditemui di Google Drive anda.`);
    }
  });
  
  Logger.log("Proses pengimporan selesai.");
}

/**
 * Helper Fungsi untuk Membuat ID Unik otomatis dari Apps Script
 */
function generateUniqueID(prefix) {
  const date = new Date();
  const formattedDate = Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyyMMdd");
  const randomNum = Math.floor(1000 + Math.random() * 9000); // 4 digit angka acak
  return `${prefix}-${formattedDate}-${randomNum}`;
}

4. Panduan Memasukkan Fail CSV Terunggah ke Spreadsheet

Ada dua kaedah utama untuk memindahkan fail CSV terunggah anda (ruangkarir - tbl_talents.csv, dsb.) ke dalam Google Spreadsheet yang telah dibina.

Kaedah A: Import Manual Secara Terbina (Disyorkan & Paling Selamat)

Jika anda ingin mengawal proses import tanpa menulis kod Google Drive tambahan, gunakan menu bawaan Google Sheets:

  1. Sediakan Struktur: Pastikan anda telah menjalankan fungsi initRuangKarirDatabase() terlebih dahulu supaya kelima-lima tab sheet (tbl_talents, tbl_companies, dsb.) sudah tersedia.

  2. Pilih Sheet Sasaran: Klik pada tab sheet yang ingin diisi (contohnya: tbl_talents).

  3. Menu Import:

    • Pergi ke menu utama: Fail (File) > Import.

    • Pilih tab Muat Naik (Upload) dan seret fail CSV padanan anda (contoh: ruangkarir - tbl_talents.csv).

  4. Tetapan Import (Sangat Penting):

    • Lokasi Import: Pilih Gantikan data pada helaian terpilih (Replace data on selected sheet).

    • Jenis Pemisah (Separator Type): Pilih Kesan automatik (Detect automatically) atau Koma.

    • Tukar Teks kepada Nombor/Tarikh: Pilih Ya.

    • Klik butang Import Data.

  5. Ulangi Proses: Lakukan perkara yang sama untuk 4 fail CSV yang lain pada tab sheet masing-masing.

Kaedah B: Import Automatik Menggunakan Google Apps Script

Kaedah ini menggunakan fungsi importUploadedCSVs() yang disertakan dalam kod di atas untuk memproses data secara automatik terus dari fail yang disimpan di storan Google Drive anda.

  1. Muat Naik ke Drive:

    • Pergi ke Google Drive anda (akun yang sama dengan Spreadsheet).

    • Muat naik kesemua 5 fail CSV tersebut di dalam direktori utama Drive atau dalam folder kerja anda dengan mengekalkan nama asal fail:

      • ruangkarir - tbl_talents.csv

      • ruangkarir - tbl_companies.csv

      • ruangkarir - tbl_jobs.csv

      • ruangkarir - tbl_applications.csv

      • ruangkarir - tbl_messages.csv

  2. Jalankan Fungsi:

    • Buka editor Apps Script (Ekstensi > Apps Script).

    • Pilih fungsi importUploadedCSVs dari menu dropdown di bahagian atas.

    • Klik butang Jalankan (Run).

  3. Berikan Kebenaran (Otorisasi):

    • Semasa pertama kali dijalankan, sistem akan meminta kebenaran untuk mengakses akaun Google Drive anda (DriveApp). Berikan kebenaran tersebut bagi membolehkan skrip mencari fail anda mengikut nama.

  4. Semak Log:

    • Buka Execution Log di bahagian bawah untuk melihat kemajuan import. Semua data CSV akan dihuraikan (parsed) dan dimasukkan tepat di bawah pengepala baris pertama tanpa merosakkan format tema premium yang sudah dibina.


Buatkan prompt untuk backendnya dengan Menggunakan metode bound script

Berkas terpisah:

Code.gs, Index.html, JavaScript.html, css.html

Menggunakan fungsi include() sisi server

Sebagai seorang Apps Script Expert, saya telah merancang arsitektur backend lengkap menggunakan metode Bound Script (melekat pada Google Spreadsheet) dengan pemisahan berkas terstruktur (Code.gs, Index.html, JavaScript.html, css.html).

Sistem ini dirancang khusus untuk memenuhi kebutuhan produksi:

  1. Include Sisi Server (<?!= include(...) ?>): Memisahkan kode HTML, CSS, dan JS agar rapi dan mudah dirawat.

  2. Normalisasi Data Ketat (Server-Side): Semua data dari Spreadsheet berupa Tanggal (Date), Angka (Float/Int), dan Boolean diubah ke format standar JSON-safe sebelum dikirim ke antarmuka klien. Hal ini mencegah kegagalan serialisasi di Google Sheets API.

  3. Pemuatan Grafik Aman (Error-Resilient Charts): Menggunakan Chart.js dengan validasi elemen DOM, penanganan data kosong, serta siklus hidup penghancuran instansi grafik lama sebelum menggambar ulang untuk menghindari galat tabrakan canvas.

Berikut adalah berkas-berkas siap pakai yang dapat langsung disalin ke Editor Google Apps Script Anda (Ekstensi > Apps Script).


Apps Script

/**
 * RuangKarir - Web App Backend (Bound Script)
 * Author: Apps Script Expert
 * Deskripsi: Mengelola CRUD, normalisasi data server-side, dan render HTML template.
 */

// 1. Fungsi Utama untuk Render Aplikasi Web
function doGet(e) {
  const template = HtmlService.createTemplateFromFile('Index');
 
  return template.evaluate()
    .setTitle('RuangKarir Hub - Portal Talent Digital & Vokasi')
    .setSandboxMode(HtmlService.SandboxMode.IFRAME)
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0');
}

// 2. Fungsi Helper untuk Menyisipkan Berkas HTML Terpisah (Include)
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

// 3. Fungsi Utilitas: Normalisasi Data Sisi Server (Sangat Penting untuk JSON-safe)
function normalizeData(headers, rowData) {
  const normalizedObject = {};
 
  headers.forEach((header, index) => {
    let value = rowData[index];
    const cleanHeader = header.trim();
   
    // A. Normalisasi Tanggal (Convert Date Object ke String ISO)
    if (value instanceof Date) {
      normalizedObject[cleanHeader] = Utilities.formatDate(value, Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
    }
    // B. Normalisasi Boolean
    else if (typeof value === 'boolean') {
      normalizedObject[cleanHeader] = value;
    } else if (String(value).toUpperCase() === 'TRUE') {
      normalizedObject[cleanHeader] = true;
    } else if (String(value).toUpperCase() === 'FALSE') {
      normalizedObject[cleanHeader] = false;
    }
    // C. Normalisasi Angka
    else if (value !== "" && !isNaN(value) && typeof value !== 'string') {
      normalizedObject[cleanHeader] = Number(value);
    }
    else if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
      normalizedObject[cleanHeader] = parseInt(value.trim(), 10);
    }
    else if (typeof value === 'string' && /^\d+\.\d+$/.test(value.trim())) {
      normalizedObject[cleanHeader] = parseFloat(value.trim());
    }
    // D. Normalisasi Teks Kosong atau Null
    else {
      normalizedObject[cleanHeader] = (value === undefined || value === null) ? "" : String(value).trim();
    }
  });
 
  return normalizedObject;
}

// 4. Mengambil Semua Data dari Google Sheets untuk Dashboard & Marketplace
function getDashboardData() {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheetNames = ["tbl_talents", "tbl_companies", "tbl_jobs", "tbl_applications", "tbl_messages"];
    const database = {};
   
    sheetNames.forEach(sheetName => {
      const sheet = ss.getSheetByName(sheetName);
      if (!sheet) {
        database[sheetName] = [];
        return;
      }
     
      const lastRow = sheet.getLastRow();
      const lastCol = sheet.getLastColumn();
     
      if (lastRow <= 1) {
        database[sheetName] = [];
        return;
      }
     
      const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
      const rows = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
     
      database[sheetName] = rows.map(row => normalizeData(headers, row));
    });
   
    return {
      success: true,
      data: database,
      message: "Data berhasil dimuat dan dinormalisasi!"
    };
  } catch (error) {
    return {
      success: false,
      message: "Gagal memuat data dari spreadsheet: " + error.toString()
    };
  }
}

// 5. Tambah Talent Baru (Pendaftaran Form)
function addTalentServer(talentData) {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName("tbl_talents");
    if (!sheet) throw new Error("Sheet tbl_talents tidak ditemukan!");
   
    const talentId = generateUniqueID("TLN");
    const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
   
    const newRow = headers.map(header => {
      switch (header) {
        case "talent_id": return talentId;
        case "nama_lengkap": return String(talentData.nama_lengkap || "").trim();
        case "email": return String(talentData.email || "").trim();
        case "role_keahlian": return String(talentData.role_keahlian || "").trim();
        case "kategori": return String(talentData.kategori || "Teknologi");
        case "badge": return String(talentData.badge || "Freelancer");
        case "rating": return parseFloat(talentData.rating || 5.0);
        case "proyek_selesai": return parseInt(talentData.proyek_selesai || 0, 10);
        case "rate_harga": return String(talentData.rate_harga || "Rp 100k/jam");
        case "skills": return String(talentData.skills || "");
        case "bio": return String(talentData.bio || "");
        case "avatar_url": return String(talentData.avatar_url || "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150");
        case "created_at": return new Date();
        default: return "";
      }
    });
   
    sheet.appendRow(newRow);
    return { success: true, talentId: talentId };
  } catch (e) {
    return { success: false, message: e.toString() };
  }
}

// 6. Tambah atau Perbarui Data Perusahaan Mitra (tbl_companies)
function addOrUpdateCompanyServer(companyData) {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName("tbl_companies");
    if (!sheet) throw new Error("Sheet tbl_companies tidak ditemukan!");
   
    const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    const lastRow = sheet.getLastRow();
   
    let companyId = companyData.company_id;
    let isUpdate = false;
    let rowIndexToUpdate = -1;
   
    // Validasi apakah id sudah ada (Mode Edit)
    if (companyId) {
      if (lastRow > 1) {
        const ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues().flat();
        const index = ids.indexOf(companyId);
        if (index !== -1) {
          isUpdate = true;
          rowIndexToUpdate = index + 2; // offset header
        }
      }
    }
   
    if (!isUpdate) {
      companyId = generateUniqueID("CMP");
    }
   
    const newRow = headers.map(header => {
      switch (header) {
        case "company_id": return companyId;
        case "nama_perusahaan": return String(companyData.nama_perusahaan || "").trim();
        case "email": return String(companyData.email || "").trim();
        case "industri": return String(companyData.industri || "").trim();
        case "lokasi": return String(companyData.lokasi || "").trim();
        case "logo_url": return String(companyData.logo_url || "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=150");
        case "created_at": return isUpdate ? (companyData.created_at || new Date()) : new Date();
        default: return "";
      }
    });
   
    if (isUpdate && rowIndexToUpdate !== -1) {
      sheet.getRange(rowIndexToUpdate, 1, 1, headers.length).setValues([newRow]);
      return { success: true, companyId: companyId, action: "update" };
    } else {
      sheet.appendRow(newRow);
      return { success: true, companyId: companyId, action: "insert" };
    }
  } catch (e) {
    return { success: false, message: e.toString() };
  }
}

// 7. Hubungi / Kirim Pesan ke Talent
function sendMessageServer(messageData) {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName("tbl_messages");
    if (!sheet) throw new Error("Sheet tbl_messages tidak ditemukan!");
   
    const messageId = generateUniqueID("MSG");
    const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
   
    const newRow = headers.map(header => {
      switch (header) {
        case "message_id": return messageId;
        case "sender_id": return String(messageData.sender_id || "CMP-GUEST");
        case "receiver_id": return String(messageData.receiver_id || "");
        case "isi_pesan": return String(messageData.isi_pesan || "").trim();
        case "sent_at": return new Date();
        default: return "";
      }
    });
   
    sheet.appendRow(newRow);
    return { success: true, messageId: messageId };
  } catch (e) {
    return { success: false, message: e.toString() };
  }
}

// 8. Tambah atau Perbarui Data Lowongan Kerja (tbl_jobs)
function addOrUpdateJobServer(jobData) {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName("tbl_jobs");
    if (!sheet) throw new Error("Sheet tbl_jobs tidak ditemukan!");
   
    const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    const lastRow = sheet.getLastRow();
   
    let jobId = jobData.job_id;
    let isUpdate = false;
    let rowIndexToUpdate = -1;
   
    // Validasi apakah ID loker sudah ada (Mode Edit)
    if (jobId) {
      if (lastRow > 1) {
        const ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues().flat();
        const index = ids.indexOf(jobId);
        if (index !== -1) {
          isUpdate = true;
          rowIndexToUpdate = index + 2; // Offset header baris ke-1
        }
      }
    }
   
    if (!isUpdate) {
      jobId = generateUniqueID("JOB");
    }
   
    // Pemetaan data berdasarkan kolom spreadsheet
    const newRow = headers.map(header => {
      switch (header) {
        case "job_id": return jobId;
        case "company_id": return String(jobData.company_id || "").trim();
        case "judul_pekerjaan": return String(jobData.judul_pekerjaan || "").trim();
        case "kategori": return String(jobData.kategori || "Teknologi");
        case "budget": return String(jobData.budget || "");
        case "lokasi": return String(jobData.lokasi || "").trim();
        case "persyaratan": return String(jobData.persyaratan || "").trim();
        case "status": return String(jobData.status || "Aktif");
        case "created_at": return isUpdate ? (jobData.created_at || new Date()) : new Date();
        default: return "";
      }
    });
   
    if (isUpdate && rowIndexToUpdate !== -1) {
      sheet.getRange(rowIndexToUpdate, 1, 1, headers.length).setValues([newRow]);
      return { success: true, jobId: jobId, action: "update" };
    } else {
      sheet.appendRow(newRow);
      return { success: true, jobId: jobId, action: "insert" };
    }
  } catch (e) {
    return { success: false, message: e.toString() };
  }
}

// Helper: Penomoran ID Unik Otomatis
function generateUniqueID(prefix) {
  const date = new Date();
  const formattedDate = Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyyMMdd");
  const randomNum = Math.floor(1000 + Math.random() * 9000);
  return `${prefix}-${formattedDate}-${randomNum}`;
}

Index.html

<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <title>RuangKarir Hub</title>
  <!-- Tailwind CSS & Google Fonts -->
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
  <!-- Chart.js CDN (Dipanggil dengan aman) -->
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <!-- Lucide Icons untuk estetika UI -->
  <script src="https://unpkg.com/lucide@latest"></script>
 
  <!-- Sisipkan kode CSS Terpisah dari css.html -->
  <?!= include('css'); ?>
</head>
<body class="bg-slate-50 text-slate-900 transition-colors duration-300">

  <!-- MAIN WRAPPER -->
  <div class="min-h-screen flex flex-col">
   
    <!-- NAVBAR -->
    <nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-100 shadow-sm px-6 py-4 flex justify-between items-center">
      <div class="flex items-center gap-2">
        <div class="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-md shadow-indigo-200">R</div>
        <span class="font-poppins font-bold text-xl bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">RuangKarir</span>
      </div>
      <div class="flex items-center gap-6">
        <button id="themeToggle" class="p-2 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-xl transition">
          <i data-lucide="sun" class="w-5 h-5"></i>
        </button>
        <span class="text-sm font-medium text-slate-500 bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-full flex items-center gap-1.5 border border-indigo-100">
          <span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> Google Sheets Active
        </span>
      </div>
    </nav>

    <!-- MAIN CONTENT -->
    <main class="flex-1 p-6 max-w-7xl mx-auto w-full space-y-8">
     
      <!-- HERO & OVERVIEW SUMMARY CARDS -->
      <div class="grid grid-cols-1 md:grid-cols-4 gap-6">
        <div class="card p-6 bg-white border border-slate-100 flex items-center gap-4">
          <div class="p-4 bg-indigo-50 text-indigo-600 rounded-2xl"><i data-lucide="users" class="w-6 h-6"></i></div>
          <div>
            <p class="text-sm text-slate-400 font-medium">Total Talent</p>
            <h3 id="statTalent" class="text-2xl font-bold font-poppins text-slate-800">0</h3>
          </div>
        </div>
        <div class="card p-6 bg-white border border-slate-100 flex items-center gap-4">
          <div class="p-4 bg-purple-50 text-purple-600 rounded-2xl"><i data-lucide="briefcase" class="w-6 h-6"></i></div>
          <div>
            <p class="text-sm text-slate-400 font-medium">Loker Aktif</p>
            <h3 id="statJobs" class="text-2xl font-bold font-poppins text-slate-800">0</h3>
          </div>
        </div>
        <div class="card p-6 bg-white border border-slate-100 flex items-center gap-4">
          <div class="p-4 bg-emerald-50 text-emerald-600 rounded-2xl"><i data-lucide="building" class="w-6 h-6"></i></div>
          <div>
            <p class="text-sm text-slate-400 font-medium">Perusahaan Mitra</p>
            <h3 id="statCompanies" class="text-2xl font-bold font-poppins text-slate-800">0</h3>
          </div>
        </div>
        <div class="card p-6 bg-white border border-slate-100 flex items-center gap-4">
          <div class="p-4 bg-amber-50 text-amber-600 rounded-2xl"><i data-lucide="file-check" class="w-6 h-6"></i></div>
          <div>
            <p class="text-sm text-slate-400 font-medium">Aksi Rekrutmen</p>
            <h3 id="statApps" class="text-2xl font-bold font-poppins text-slate-800">0</h3>
          </div>
        </div>
      </div>

      <!-- VISUALISASI DATA (SECURE CHARTS SECTION) -->
      <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
        <!-- Chart 1: Kategori Talent -->
        <div class="card p-6 bg-white border border-slate-100 flex flex-col">
          <h4 class="font-poppins font-semibold text-slate-700 mb-4 flex items-center gap-2">
            <i data-lucide="pie-chart" class="text-indigo-600 w-5 h-5"></i> Distribusi Kategori Talent
          </h4>
          <div class="relative flex-1 min-h-[250px] flex items-center justify-center">
            <canvas id="categoryChart"></canvas>
            <div id="categoryChartFallback" class="absolute inset-0 flex items-center justify-center bg-slate-50/50 hidden">
              <span class="text-sm text-slate-400">Tidak ada data untuk direpresentasikan</span>
            </div>
          </div>
        </div>

        <!-- Chart 2: Status Pekerjaan -->
        <div class="card p-6 bg-white border border-slate-100 flex flex-col">
          <h4 class="font-poppins font-semibold text-slate-700 mb-4 flex items-center gap-2">
            <i data-lucide="bar-chart-3" class="text-purple-600 w-5 h-5"></i> Aliansi / Tipe Jasa Talent
          </h4>
          <div class="relative flex-1 min-h-[250px] flex items-center justify-center">
            <canvas id="badgeChart"></canvas>
            <div id="badgeChartFallback" class="absolute inset-0 flex items-center justify-center bg-slate-50/50 hidden">
              <span class="text-sm text-slate-400">Tidak ada data untuk direpresentasikan</span>
            </div>
          </div>
        </div>
      </div>

      <!-- TAB DIRECTORY CONTROLLER & MAIN DATA TABLES -->
      <div class="card p-6 bg-white border border-slate-100 space-y-6">
       
        <!-- Tab Navigation & Action Buttons -->
        <div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 pb-4 border-b border-slate-100">
          <div class="flex bg-slate-100 p-1 rounded-2xl gap-1">
            <button id="tabTalents" class="px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 bg-white text-slate-800 shadow-sm flex items-center gap-2">
              <i data-lucide="users" class="w-4 h-4"></i> Direktori Talent
            </button>
            <button id="tabCompanies" class="px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2">
              <i data-lucide="building" class="w-4 h-4"></i> Mitra Perusahaan
            </button>
            <button id="tabJobs" class="px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2">
              <i data-lucide="briefcase" class="w-4 h-4"></i> Lowongan Kerja
            </button>
          </div>

          <div class="flex flex-wrap gap-3 w-full lg:w-auto">
            <div class="relative flex-1 lg:w-72">
              <i data-lucide="search" class="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 w-4 h-4"></i>
              <input type="text" id="globalSearch" placeholder="Cari di direktori aktif..." class="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/20 text-sm">
            </div>
           
            <button id="btnOpenTalentModal" class="bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-4 py-2 rounded-xl text-sm flex items-center gap-2 transition shadow-lg shadow-indigo-100">
              <i data-lucide="user-plus" class="w-4 h-4"></i> Daftarkan Talent
            </button>
            <button id="btnOpenCompModal" class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-xl text-sm flex items-center gap-2 transition shadow-lg shadow-emerald-100">
              <i data-lucide="plus-circle" class="w-4 h-4"></i> Tambah Mitra
            </button>
            <button id="btnOpenJobModal" class="bg-purple-600 hover:bg-purple-700 text-white font-medium px-4 py-2 rounded-xl text-sm flex items-center gap-2 transition shadow-lg shadow-purple-100">
              <i data-lucide="file-plus" class="w-4 h-4"></i> Terbitkan Loker
            </button>
          </div>
        </div>

        <!-- SECTION A: TALENTS DIRECTORY CONTAINER -->
        <div id="talentsSection" class="space-y-4">
          <div class="overflow-x-auto rounded-xl border border-slate-100">
            <table class="w-full text-left border-collapse">
              <thead>
                <tr class="bg-slate-50/75 border-b border-slate-100">
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Talent ID</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Nama & Kontak</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Role & Spesialisasi</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Klasifikasi Jasa</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Rating & Proyek</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider text-right">Aksi</th>
                </tr>
              </thead>
              <tbody id="talentTableBody" class="divide-y divide-slate-100">
                <tr>
                  <td colspan="6" class="p-8 text-center text-slate-400">
                    <div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-indigo-500 border-t-transparent mb-2"></div>
                    <p class="text-sm font-medium">Memuat data talenta...</p>
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
        </div>

        <!-- SECTION B: COMPANIES DIRECTORY CONTAINER (Hidden on Init) -->
        <div id="companiesSection" class="space-y-4 hidden">
        <!-- SECTION C: JOBS DIRECTORY CONTAINER (Hidden on Init) -->
        <div id="jobsSection" class="space-y-4 hidden">
          <div class="overflow-x-auto rounded-xl border border-slate-100">
            <table class="w-full text-left border-collapse">
              <thead>
                <tr class="bg-slate-50/75 border-b border-slate-100">
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Loker ID</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Perusahaan</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Posisi Lowongan</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Budget / Gaji</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Status & Lokasi</th>
                  <th class="p-4 text-xs font-semibold text-slate-500 uppercase tracking-wider text-right">Aksi</th>
                </tr>
              </thead>
              <tbody id="jobTableBody" class="divide-y divide-slate-100">
                <tr>
                  <td colspan="6" class="p-8 text-center text-slate-400">
                    <div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-purple-500 border-t-transparent mb-2"></div>
                    <p class="text-sm font-medium">Memuat data lowongan kerja aktif...</p>
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
        </div>

      </div>

    </main>

    <!-- FOOTER -->
    <footer class="bg-white border-t border-slate-100 py-6 text-center text-sm text-slate-400">
      <p>© 2026 RuangKarir Marketplace. Didukung penuh oleh Google Apps Script & Spreadsheet Database.</p>
    </footer>
  </div>

  <!-- TOAST NOTIFICATION SYSTEM -->
  <div id="toast" class="fixed bottom-6 right-6 bg-slate-900 text-white px-5 py-3 rounded-2xl shadow-xl flex items-center gap-3 translate-y-20 opacity-0 transition-all duration-300 pointer-events-none z-55">
    <div id="toastIcon" class="p-1.5 bg-emerald-500 text-white rounded-lg"><i data-lucide="check" class="w-4 h-4"></i></div>
    <span id="toastMsg" class="text-sm font-medium">Data berhasil disimpan!</span>
  </div>

  <!-- MODAL: DAFTAR TALENT BARU -->
  <div id="modalTalent" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center pointer-events-none opacity-0 transition-all duration-300">
    <div class="bg-white w-full max-w-xl mx-4 rounded-3xl shadow-2xl overflow-hidden transform scale-95 transition-all duration-300 max-h-[90vh] flex flex-col">
      <div class="p-6 bg-gradient-to-r from-indigo-600 to-purple-600 text-white flex justify-between items-center">
        <div>
          <h3 class="font-poppins font-bold text-lg">Daftar sebagai Talent</h3>
          <p class="text-xs text-indigo-100">Data Anda akan dinormalisasi sebelum disimpan ke Spreadsheet.</p>
        </div>
        <button id="btnCloseModal" class="p-2 bg-white/10 hover:bg-white/20 rounded-xl transition text-white">
          <i data-lucide="x" class="w-5 h-5"></i>
        </button>
      </div>
     
      <form id="talentForm" class="p-6 overflow-y-auto space-y-4 flex-1">
        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
          <div class="space-y-1">
            <label class="text-xs font-semibold text-slate-500">Nama Lengkap</label>
            <input type="text" name="nama_lengkap" required class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm">
          </div>
          <div class="space-y-1">
            <label class="text-xs font-semibold text-slate-500">Email Utama</label>
            <input type="email" name="email" required class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm">
          </div>
        </div>
       
        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
          <div class="space-y-1">
            <label class="text-xs font-semibold text-slate-500">Role Pekerjaan / Keahlian</label>
            <input type="text" name="role_keahlian" placeholder="Contoh: UI/UX Designer" required class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm">
          </div>
          <div class="space-y-1">
            <label class="text-xs font-semibold text-slate-500">Kategori Industri</label>
            <select name="kategori" class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm">
              <option value="Teknologi">Teknologi</option>
              <option value="Desain">Desain</option>
              <option value="Kreator">Kreator</option>
              <option value="Penulis">Penulis</option>
            </select>
          </div>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
          <div class="space-y-1">
            <label class="text-xs font-semibold text-slate-500">Klasifikasi Jasa</label>
            <select name="badge" class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm">
              <option value="Alumni Vokasi">Alumni Vokasi</option>
              <option value="Kreator Digital">Kreator Digital</option>
              <option value="Freelancer">Freelancer</option>
            </select>
          </div>
          <div class="space-y-1">
            <label class="text-xs font-semibold text-slate-500">Tarif Kerja (Rate Jasa)</label>
            <input type="text" name="rate_harga" placeholder="Contoh: Rp 150k/jam" required class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm">
          </div>
        </div>

        <div class="space-y-1">
          <label class="text-xs font-semibold text-slate-500">Daftar Tag Skill (Pisahkan dengan koma)</label>
          <input type="text" name="skills" placeholder="Figma, React, Tailwind, Git" required class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm">
        </div>

        <div class="space-y-1">
          <label class="text-xs font-semibold text-slate-500">Ringkasan Portofolio (Bio)</label>
          <textarea name="bio" rows="3" placeholder="Jelaskan secara ringkas pencapaian luar biasa Anda..." required class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm"></textarea>
        </div>

        <div class="pt-4 border-t border-slate-100 flex justify-end gap-3">
          <button type="button" id="btnCancelForm" class="px-4 py-2 bg-slate-100 hover:bg-slate-200 rounded-xl text-slate-600 font-medium text-sm transition">Batalkan</button>
          <button type="submit" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl text-sm transition flex items-center gap-2 shadow-lg shadow-indigo-100">
            <i data-lucide="save" class="w-4 h-4"></i> Simpan Data
          </button>
        </div>
      </form>
    </div>
  </div>

  <!-- MODAL: HUBUNGI TALENT (KIRIM PESAN LANGSUNG) -->
  <div id="modalMessage" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center pointer-events-none opacity-0 transition-all duration-300">
    <div class="bg-white w-full max-w-md mx-4 rounded-3xl shadow-2xl overflow-hidden transform scale-95 transition-all duration-300 p-6 space-y-4">
      <div class="flex justify-between items-center">
        <div>
          <h3 class="font-poppins font-bold text-lg text-slate-800">Hubungi Talent</h3>
          <p class="text-xs text-slate-400">Pesan Anda akan dicatat pada `tbl_messages`</p>
        </div>
        <button id="btnCloseMsgModal" class="p-2 hover:bg-slate-100 rounded-xl transition text-slate-400">
          <i data-lucide="x" class="w-5 h-5"></i>
        </button>
      </div>

      <div class="flex items-center gap-3 p-3 bg-slate-50 rounded-2xl">
        <img id="msgTargetAvatar" src="" class="w-10 h-10 rounded-full object-cover">
        <div>
          <h4 id="msgTargetName" class="font-semibold text-sm text-slate-800">Nama Talent</h4>
          <p id="msgTargetRole" class="text-xs text-slate-400">Role Keahlian</p>
        </div>
      </div>

      <form id="messageForm" class="space-y-4">
        <input type="hidden" name="receiver_id" id="msgTargetId">
        <div class="space-y-1">
          <label class="text-xs font-semibold text-slate-500">Pesan Penawaran Projek / Interview</label>
          <textarea name="isi_pesan" rows="4" required placeholder="Halo, kami tertarik dengan jasa Anda. Bisakah kami menjadwalkan sesi meeting untuk mendiskusikan penawaran..." class="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500/20 text-sm"></textarea>
        </div>

        <div class="flex justify-end gap-3 pt-2">
          <button type="button" id="btnCancelMsgForm" class="px-4 py-2 bg-slate-100 hover:bg-slate-200 rounded-xl text-slate-600 font-medium text-sm transition">Batal</button>
          <button type="submit" class="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl text-sm transition flex items-center gap-2 shadow-lg shadow-indigo-100">
            <i data-lucide="send" class="w-4 h-4"></i> Kirim Pesan
          </button>
        </div>
      </form>
    </div>
  </div>

  <!-- Sisipkan Berkas Baru HTML template modul Company -->
  <?!= include('company'); ?>

  <!-- Sisipkan Berkas Baru HTML template modul Loker -->
  <?!= include('loker'); ?>

  <!-- Sisipkan kode Client-Side JS Terpisah dari JavaScript.html -->
  <?!= include('JavaScript'); ?>
</body>
</html>

css.html

<style>
  /* Desain Sistem - RuangKarir */
  body {
    font-family: 'Inter', sans-serif;
  }
 
  h1, h2, h3, h4, .font-poppins {
    font-family: 'Poppins', sans-serif;
  }

  /* Animasi Transisi & Interaksi */
  .card {
    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
    border-radius: 20px;
  }
 
  .card:hover {
    transform: translateY(-4px);
    box-shadow: 0 12px 24px -10px rgba(91, 91, 214, 0.12);
  }

  /* Kustomisasi Scrollbar Minimalis */
  ::-webkit-scrollbar {
    width: 6px;
    height: 6px;
  }
  ::-webkit-scrollbar-track {
    background: transparent;
  }
  ::-webkit-scrollbar-thumb {
    background: #cbd5e1;
    border-radius: 10px;
  }
  ::-webkit-scrollbar-thumb:hover {
    background: #94a3b8;
  }

  /* Pola Modul Dialog Aktif */
  .modal-active {
    opacity: 1 !important;
    pointer-events: auto !important;
  }
  .modal-active > div {
    transform: scale(1) !important;
  }

  /* Dark Mode Stylesheet */
  body.dark {
    background-color: #0f172a;
    color: #f8fafc;
  }
  body.dark .bg-white {
    background-color: #1e293b;
    border-color: #334155;
  }
  body.dark .bg-slate-50 {
    background-color: #0f172a;
  }
  body.dark .text-slate-900 {
    color: #f8fafc;
  }
  body.dark .text-slate-800 {
    color: #e2e8f0;
  }
  body.dark .text-slate-700 {
    color: #cbd5e1;
  }
  body.dark .text-slate-500 {
    color: #94a3b8;
  }
  body.dark .border-slate-100, body.dark .border-slate-200 {
    border-color: #334155;
  }
  body.dark .bg-slate-50\/75 {
    background-color: #1e293b;
  }
  body.dark input, body.dark select, body.dark textarea {
    background-color: #1e293b;
    border-color: #334155;
    color: #f8fafc;
  }
</style>

JavaScript.html

<script>
  /**
   * RuangKarir - Client-Side App Logic
   * Deskripsi: Menghandle pemuatan data, pencarian dinamis, error-handling grafik, dan mutasi data ke server.
   */

  // Menyimpan referensi grafik global secara terstruktur untuk mencegah tabrakan instansi kanvas
  window.myCharts = {
    category: null,
    badge: null
  };

  // State Utama Aplikasi Web
  let appState = {
    talents: [],
    companies: [],
    jobs: [],
    applications: [],
    messages: []
  };

  // Menyimpan tab yang aktif pada menu direktori ('talents', 'companies', atau 'jobs')
  let activeDirectoryTab = 'talents';

  // 1. Eksekusi Saat Dokumen Selesai Dimuat
  document.addEventListener('DOMContentLoaded', () => {
    lucide.createIcons();
    initAppEvents();
    fetchDashboardData();
  });

  // 2. Pasang Semua Event Listener pada UI
  function initAppEvents() {
    // Kontrol Tema (Light/Dark Mode)
    const themeBtn = document.getElementById('themeToggle');
    themeBtn.addEventListener('click', () => {
      const isDark = document.body.classList.toggle('dark');
      themeBtn.innerHTML = isDark
        ? `<i data-lucide="moon" class="w-5 h-5 text-indigo-400"></i>`
        : `<i data-lucide="sun" class="w-5 h-5"></i>`;
      lucide.createIcons();
    });

    // Kontrol Tab Toggle Antara Direktori Talent dan Perusahaan
    const tabTalentsBtn = document.getElementById('tabTalents');
    const tabCompaniesBtn = document.getElementById('tabCompanies');
    const tabJobsBtn = document.getElementById('tabJobs');
   
    const talentsSection = document.getElementById('talentsSection');
    const companiesSection = document.getElementById('companiesSection');
    const jobsSection = document.getElementById('jobsSection');

    tabTalentsBtn.addEventListener('click', () => {
      activeDirectoryTab = 'talents';
      tabTalentsBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 bg-white text-slate-800 shadow-sm flex items-center gap-2";
      tabCompaniesBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2";
      tabJobsBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2";
      talentsSection.classList.remove('hidden');
      companiesSection.classList.add('hidden');
      jobsSection.classList.add('hidden');
    });

    tabCompaniesBtn.addEventListener('click', () => {
      activeDirectoryTab = 'companies';
      tabCompaniesBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 bg-white text-slate-800 shadow-sm flex items-center gap-2";
      tabTalentsBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2";
      tabJobsBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2";
      companiesSection.classList.remove('hidden');
      talentsSection.classList.add('hidden');
      jobsSection.classList.add('hidden');
    });

    tabJobsBtn.addEventListener('click', () => {
      activeDirectoryTab = 'jobs';
      tabJobsBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 bg-white text-slate-800 shadow-sm flex items-center gap-2";
      tabTalentsBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2";
      tabCompaniesBtn.className = "px-5 py-2.5 rounded-xl font-poppins font-semibold text-sm transition-all duration-200 text-slate-500 hover:text-slate-700 flex items-center gap-2";
      jobsSection.classList.remove('hidden');
      talentsSection.classList.add('hidden');
      companiesSection.classList.add('hidden');
    });

    // Kontrol Modal Registrasi Talent
    const modalTalent = document.getElementById('modalTalent');
    document.getElementById('btnOpenTalentModal').addEventListener('click', () => modalTalent.classList.add('modal-active'));
    document.getElementById('btnCloseModal').addEventListener('click', () => modalTalent.classList.remove('modal-active'));
    document.getElementById('btnCancelForm').addEventListener('click', () => modalTalent.classList.remove('modal-active'));

    // Kontrol Modal Registrasi & Edit Perusahaan
    const modalCompany = document.getElementById('modalCompany');
    document.getElementById('btnOpenCompModal').addEventListener('click', () => {
      openCompanyFormForCreate();
    });
    document.getElementById('btnCloseCompModal').addEventListener('click', () => modalCompany.classList.remove('modal-active'));
    document.getElementById('btnCancelCompForm').addEventListener('click', () => modalCompany.classList.remove('modal-active'));

    // Kontrol Modal Registrasi & Edit Lowongan Kerja (Loker)
    const modalJob = document.getElementById('modalJob');
    document.getElementById('btnOpenJobModal').addEventListener('click', () => {
      openJobFormForCreate();
    });
    document.getElementById('btnCloseJobModal').addEventListener('click', () => modalJob.classList.remove('modal-active'));
    document.getElementById('btnCancelJobForm').addEventListener('click', () => modalJob.classList.remove('modal-active'));

    // Kontrol Modal Pengiriman Pesan
    const modalMessage = document.getElementById('modalMessage');
    document.getElementById('btnCloseMsgModal').addEventListener('click', () => modalMessage.classList.remove('modal-active'));
    document.getElementById('btnCancelMsgForm').addEventListener('click', () => modalMessage.classList.remove('modal-active'));

    // Penanganan Formulir Pendaftaran Talent Baru (Sisi Klien)
    document.getElementById('talentForm').addEventListener('submit', function(e) {
      e.preventDefault();
      const submitBtn = this.querySelector('button[type="submit"]');
      const originalText = submitBtn.innerHTML;
     
      const formData = new FormData(this);
      const talentPayload = {
        nama_lengkap: formData.get('nama_lengkap'),
        email: formData.get('email'),
        role_keahlian: formData.get('role_keahlian'),
        kategori: formData.get('kategori'),
        badge: formData.get('badge'),
        rate_harga: formData.get('rate_harga'),
        skills: formData.get('skills'),
        bio: formData.get('bio'),
        rating: 5.0,
        proyek_selesai: 0
      };

      submitBtn.disabled = true;
      submitBtn.innerHTML = `<span class="inline-block animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></span> Menyimpan...`;

      google.script.run
        .withSuccessHandler((response) => {
          submitBtn.disabled = false;
          submitBtn.innerHTML = originalText;
         
          if (response.success) {
            modalTalent.classList.remove('modal-active');
            this.reset();
            showToast("Talent berhasil didaftarkan!", "check", "emerald");
            fetchDashboardData();
          } else {
            showToast("Server gagal memproses: " + response.message, "alert-circle", "red");
          }
        })
        .withFailureHandler((err) => {
          submitBtn.disabled = false;
          submitBtn.innerHTML = originalText;
          showToast("Koneksi gagal: " + err.toString(), "alert-circle", "red");
        })
        .addTalentServer(talentPayload);
    });

    // Penanganan Formulir Pendaftaran & Pembaruan Mitra Perusahaan
    document.getElementById('companyForm').addEventListener('submit', function(e) {
    // Penanganan Formulir Pembuatan & Perbaruan Lowongan Kerja (Loker)
    document.getElementById('jobForm').addEventListener('submit', function(e) {
      e.preventDefault();
      const submitBtn = document.getElementById('btnSubmitJob');
      const originalText = submitBtn.innerHTML;

      const formData = new FormData(this);
      const jobPayload = {
        job_id: formData.get('job_id') || "",
        company_id: formData.get('company_id'),
        judul_pekerjaan: formData.get('judul_pekerjaan'),
        kategori: formData.get('kategori'),
        budget: formData.get('budget'),
        lokasi: formData.get('lokasi'),
        persyaratan: formData.get('persyaratan'),
        status: formData.get('status'),
        created_at: formData.get('created_at') || ""
      };

      submitBtn.disabled = true;
      submitBtn.innerHTML = `<span class="inline-block animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></span> Memproses...`;

      google.script.run
        .withSuccessHandler((response) => {
          submitBtn.disabled = false;
          submitBtn.innerHTML = originalText;

          if (response.success) {
            modalJob.classList.remove('modal-active');
            this.reset();
           
            const actionText = response.action === 'update' ? "Lowongan pekerjaan diperbarui!" : "Lowongan baru sukses diterbitkan!";
            showToast(actionText, "check", "emerald");
           
            // Auto switch ke tab Lowongan Kerja untuk konfirmasi visual
            document.getElementById('tabJobs').click();
            fetchDashboardData();
          } else {
            showToast("Gagal menerbitkan loker: " + response.message, "alert-circle", "red");
          }
        })
        .withFailureHandler((err) => {
          submitBtn.disabled = false;
          submitBtn.innerHTML = originalText;
          showToast("Koneksi gagal: " + err.toString(), "alert-circle", "red");
        })
        .addOrUpdateJobServer(jobPayload);
    });

    // Penanganan Formulir Pengiriman Pesan
    document.getElementById('messageForm').addEventListener('submit', function(e) {
      e.preventDefault();
      const submitBtn = this.querySelector('button[type="submit"]');
      const originalText = submitBtn.innerHTML;
     
      const formData = new FormData(this);
      const messagePayload = {
        sender_id: "CMP-ADMIN",
        receiver_id: formData.get('receiver_id'),
        isi_pesan: formData.get('isi_pesan')
      };

      submitBtn.disabled = true;
      submitBtn.innerHTML = `<span class="inline-block animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></span> Mengirim...`;

      google.script.run
        .withSuccessHandler((response) => {
          submitBtn.disabled = false;
          submitBtn.innerHTML = originalText;
         
          if (response.success) {
            modalMessage.classList.remove('modal-active');
            this.reset();
            showToast("Pesan penawaran berhasil dikirim!", "send", "indigo");
          } else {
            showToast("Gagal mengirim pesan: " + response.message, "alert-circle", "red");
          }
        })
        .withFailureHandler((err) => {
          submitBtn.disabled = false;
          submitBtn.innerHTML = originalText;
          showToast("Koneksi gagal: " + err.toString(), "alert-circle", "red");
        })
        .sendMessageServer(messagePayload);
    });

    // Fitur Pencarian Dinamis Gabungan (Live Client-Side Search)
    document.getElementById('globalSearch').addEventListener('input', function(e) {
      const keyword = e.target.value.toLowerCase().trim();
      if (activeDirectoryTab === 'talents') {
        filterTalentsOnTable(keyword);
      } else if (activeDirectoryTab === 'companies') {
        filterCompaniesOnTable(keyword);
      } else {
        filterJobsOnTable(keyword);
      }
    });
  }

  // 3. Menghubungi Server-Side untuk Mengambil Data Spreadsheet
  function fetchDashboardData() {
    google.script.run
      .withSuccessHandler((response) => {
        if (response.success && response.data) {
          appState.talents = response.data.tbl_talents || [];
          appState.companies = response.data.tbl_companies || [];
          appState.jobs = response.data.tbl_jobs || [];
          appState.applications = response.data.tbl_applications || [];
          appState.messages = response.data.tbl_messages || [];

          // Perbarui Tampilan Statistik Utama
          updateStatCards();
         
          // Render Ketiga Tabel Utama
          renderTalentTable(appState.talents);
          renderCompanyTable(appState.companies);
          renderJobTable(appState.jobs);
         
          // Sinkronisasi pilihan dropdown perusahaan saat mendaftarkan loker baru
          populateCompanyDropdown();
         
          // Render Grafik Secara Aman
          renderChartsResiliently();
        } else {
          showToast("Error sistem: " + response.message, "alert-circle", "red");
        }
      })
      .withFailureHandler((err) => {
        showToast("Gagal terhubung ke Google Spreadsheet: " + err.toString(), "alert-circle", "red");
      })
      .getDashboardData();
  }

  // 4. Update Angka Statistik Kartu Overview
  function updateStatCards() {
    document.getElementById('statTalent').innerText = appState.talents.length;
    document.getElementById('statJobs').innerText = appState.jobs.length;
    document.getElementById('statCompanies').innerText = appState.companies.length;
    document.getElementById('statApps').innerText = appState.applications.length;
  }

  // 5. Render Data Talent ke Baris Tabel HTML
  function renderTalentTable(talentsList) {
    const tableBody = document.getElementById('talentTableBody');
    tableBody.innerHTML = '';

    if (talentsList.length === 0) {
      tableBody.innerHTML = `
        <tr>
          <td colspan="6" class="p-8 text-center text-slate-400">
            <i data-lucide="inbox" class="w-8 h-8 mx-auto mb-2 text-slate-300"></i>
            <p class="text-sm">Tidak ada data talent aktif yang tersimpan di Google Sheet.</p>
          </td>
        </tr>
      `;
      lucide.createIcons();
      return;
    }

    talentsList.forEach(talent => {
      let badgeClass = 'bg-slate-50 text-slate-600 border-slate-100';
      if (talent.badge === 'Alumni Vokasi') badgeClass = 'bg-indigo-50 text-indigo-700 border-indigo-100';
      else if (talent.badge === 'Kreator Digital') badgeClass = 'bg-purple-50 text-purple-700 border-purple-100';
      else if (talent.badge === 'Freelancer') badgeClass = 'bg-amber-50 text-amber-700 border-amber-100';

      const tr = document.createElement('tr');
      tr.className = "hover:bg-slate-50/50 transition duration-150";
      tr.innerHTML = `
        <td class="p-4 text-xs font-mono font-bold text-slate-400">${talent.talent_id}</td>
        <td class="p-4">
          <div class="flex items-center gap-3">
            <img src="${talent.avatar_url}" class="w-10 h-10 rounded-full object-cover border border-slate-100 bg-slate-50">
            <div>
              <h5 class="font-semibold text-sm text-slate-800 dark:text-slate-100">${talent.nama_lengkap}</h5>
              <p class="text-xs text-slate-400">${talent.email}</p>
            </div>
          </div>
        </td>
        <td class="p-4">
          <div class="font-medium text-sm text-slate-700 dark:text-slate-300">${talent.role_keahlian}</div>
          <span class="text-[10px] font-semibold tracking-wide uppercase px-2 py-0.5 bg-slate-100 rounded-md text-slate-500">${talent.kategori}</span>
        </td>
        <td class="p-4">
          <span class="text-xs font-medium px-2.5 py-1 rounded-full border ${badgeClass}">${talent.badge}</span>
        </td>
        <td class="p-4">
          <div class="flex items-center gap-1 text-amber-500 font-bold text-sm">
            <i data-lucide="star" class="w-4 h-4 fill-amber-400 text-amber-400"></i> ${Number(talent.rating).toFixed(1)}
          </div>
          <p class="text-[11px] text-slate-400">${talent.proyek_selesai} Proyek Selesai</p>
        </td>
        <td class="p-4 text-right">
          <button onclick="openMessageDialog('${talent.talent_id}', '${talent.nama_lengkap}', '${talent.role_keahlian}', '${talent.avatar_url}')" class="p-2 bg-indigo-50 text-indigo-600 hover:bg-indigo-600 hover:text-white rounded-xl transition inline-flex items-center justify-center">
            <i data-lucide="message-square" class="w-4.5 h-4.5"></i>
          </button>
        </td>
      `;
      tableBody.appendChild(tr);
    });

    lucide.createIcons();
  }

  // 6. Render Data Company ke Baris Tabel HTML
  function renderCompanyTable(companiesList) {
  // 6b. Render Data Lowongan Pekerjaan (Loker) ke Baris Tabel HTML
  function renderJobTable(jobsList) {
    const tableBody = document.getElementById('jobTableBody');
    tableBody.innerHTML = '';

    if (jobsList.length === 0) {
      tableBody.innerHTML = `
        <tr>
          <td colspan="6" class="p-8 text-center text-slate-400">
            <i data-lucide="inbox" class="w-8 h-8 mx-auto mb-2 text-slate-300"></i>
            <p class="text-sm">Belum ada lowongan pekerjaan aktif saat ini.</p>
          </td>
        </tr>
      `;
      lucide.createIcons();
      return;
    }

    jobsList.forEach(job => {
      // Cari nama perusahaan berdasarkan company_id secara dinamis
      const company = appState.companies.find(c => c.company_id === job.company_id);
      const companyName = company ? company.nama_perusahaan : "Perusahaan Non-Mitra";
      const companyLogo = company && company.logo_url ? company.logo_url : "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=150";

      let statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-100';
      if (job.status === 'Selesai') statusBadge = 'bg-rose-50 text-rose-700 border-rose-100';
      else if (job.status === 'Draf') statusBadge = 'bg-slate-50 text-slate-700 border-slate-100';

      const tr = document.createElement('tr');
      tr.className = "hover:bg-slate-50/50 transition duration-150";
      tr.innerHTML = `
        <td class="p-4 text-xs font-mono font-bold text-slate-400">${job.job_id}</td>
        <td class="p-4">
          <div class="flex items-center gap-3">
            <img src="${companyLogo}" class="w-10 h-10 rounded-xl object-cover border border-slate-100 bg-slate-50">
            <h5 class="font-semibold text-sm text-slate-800 dark:text-slate-100">${companyName}</h5>
          </div>
        </td>
        <td class="p-4">
          <div class="font-semibold text-sm text-slate-700 dark:text-slate-300">${job.judul_pekerjaan}</div>
          <span class="text-[10px] font-semibold tracking-wide uppercase px-2 py-0.5 bg-slate-100 rounded-md text-slate-500">${job.kategori}</span>
        </td>
        <td class="p-4 text-sm font-semibold text-slate-600 dark:text-slate-300">${job.budget}</td>
        <td class="p-4">
          <span class="text-xs font-medium px-2.5 py-1 rounded-full border ${statusBadge}">${job.status}</span>
          <p class="text-[11px] text-slate-400 mt-1"><i class="inline-block w-3 h-3" data-lucide="map-pin"></i> ${job.lokasi}</p>
        </td>
        <td class="p-4 text-right">
          <button onclick="openJobFormForEdit('${job.job_id}')" class="p-2 bg-purple-50 text-purple-600 hover:bg-purple-600 hover:text-white rounded-xl transition inline-flex items-center justify-center" title="Edit Detail Lowongan">
            <i data-lucide="edit-3" class="w-4.5 h-4.5"></i>
          </button>
        </td>
      `;
      tableBody.appendChild(tr);
    });

    lucide.createIcons();
  }

  // 7. Pencarian Realtime Client-Side (Talent)
  function filterTalentsOnTable(keyword) {
    const filtered = appState.talents.filter(talent => {
      return talent.nama_lengkap.toLowerCase().includes(keyword) ||
             talent.role_keahlian.toLowerCase().includes(keyword) ||
             talent.skills.toLowerCase().includes(keyword) ||
             talent.talent_id.toLowerCase().includes(keyword);
    });
    renderTalentTable(filtered);
  }

  // 8. Pencarian Realtime Client-Side (Perusahaan)
  function filterCompaniesOnTable(keyword) {
  // 8b. Pencarian Realtime Client-Side (Loker)
  function filterJobsOnTable(keyword) {
    const filtered = appState.jobs.filter(job => {
      return job.judul_pekerjaan.toLowerCase().includes(keyword) ||
             job.kategori.toLowerCase().includes(keyword) ||
             job.lokasi.toLowerCase().includes(keyword) ||
             job.job_id.toLowerCase().includes(keyword);
    });
    renderJobTable(filtered);
  }

  // 9. Penanganan Form Modal Perusahaan (Mode Create)
  function openCompanyFormForCreate() {
    document.getElementById('companyForm').reset();
    document.getElementById('compFormId').value = "";
    document.getElementById('compFormCreatedAt').value = "";
    document.getElementById('companyModalTitle').innerText = "Daftarkan Perusahaan Mitra";
    document.getElementById('modalCompany').classList.add('modal-active');
  }

  // 10. Penanganan Form Modal Perusahaan (Mode Edit - Auto Load Data)
  function openCompanyFormForEdit(companyId) {
  // 10b. Penanganan Form Modal Loker (Mode Create)
  function openJobFormForCreate() {
    document.getElementById('jobForm').reset();
    document.getElementById('jobFormId').value = "";
    document.getElementById('jobFormCreatedAt').value = "";
    document.getElementById('jobModalTitle').innerText = "Terbitkan Lowongan Kerja";
    document.getElementById('modalJob').classList.add('modal-active');
  }

  // 10c. Penanganan Form Modal Loker (Mode Edit - Auto Load Data)
  function openJobFormForEdit(jobId) {
    const job = appState.jobs.find(j => j.job_id === jobId);
    if (!job) {
      showToast("Data lowongan kerja tidak ditemukan!", "alert-circle", "red");
      return;
    }

    // Isi Form otomatis dari data baris spreadsheet
    document.getElementById('jobFormId').value = job.job_id;
    document.getElementById('jobFormCompany').value = job.company_id;
    document.getElementById('jobFormJudul').value = job.judul_pekerjaan;
    document.getElementById('jobFormKategori').value = job.kategori;
    document.getElementById('jobFormBudget').value = job.budget;
    document.getElementById('jobFormLokasi').value = job.lokasi;
    document.getElementById('jobFormStatus').value = job.status;
    document.getElementById('jobFormPersyaratan').value = job.persyaratan;
    document.getElementById('jobFormCreatedAt').value = job.created_at;

    document.getElementById('jobModalTitle').innerText = "Perbarui Lowongan Kerja";
    document.getElementById('modalJob').classList.add('modal-active');
  }

  // 10d. Otomatisasi Populating Dropdown Perusahaan dari tbl_companies
  function populateCompanyDropdown() {
    const dropdown = document.getElementById('jobFormCompany');
    // Sisakan opsi default pertama
    dropdown.innerHTML = '<option value="">-- Pilih Perusahaan Mitra --</option>';

    appState.companies.forEach(company => {
      const option = document.createElement('option');
      option.value = company.company_id;
      option.text = company.nama_perusahaan;
      dropdown.appendChild(option);
    });
  }

  // 11. Penanganan Pembukaan Modal Kirim Pesan
  function openMessageDialog(id, nama, role, avatar) {
    document.getElementById('msgTargetId').value = id;
    document.getElementById('msgTargetName').innerText = nama;
    document.getElementById('msgTargetRole').innerText = role;
    document.getElementById('msgTargetAvatar').src = avatar;
   
    document.getElementById('modalMessage').classList.add('modal-active');
  }

  // 12. Pemuatan Grafik Resilient
  function renderChartsResiliently() {
    try {
      const categoryCanvas = document.getElementById('categoryChart');
      const categoryFallback = document.getElementById('categoryChartFallback');
     
      if (categoryCanvas) {
        if (window.myCharts.category) {
          window.myCharts.category.destroy();
        }

        const categoryMap = {};
        appState.talents.forEach(t => {
          if (t.kategori) categoryMap[t.kategori] = (categoryMap[t.kategori] || 0) + 1;
        });

        const labels = Object.keys(categoryMap);
        const values = Object.values(categoryMap);

        if (labels.length === 0) {
          categoryFallback.classList.remove('hidden');
        } else {
          categoryFallback.classList.add('hidden');
          window.myCharts.category = new Chart(categoryCanvas.getContext('2d'), {
            type: 'doughnut',
            data: {
              labels: labels,
              datasets: [{
                data: values,
                backgroundColor: ['#5B5BD6', '#a855f7', '#22C55E', '#f59e0b', '#f43f5e'],
                borderWidth: 2,
                borderColor: '#ffffff'
              }]
            },
            options: {
              responsive: true,
              maintainAspectRatio: false,
              plugins: {
                legend: { position: 'bottom', labels: { boxWidth: 12, font: { family: 'Inter' } } }
              }
            }
          });
        }
      }

      const badgeCanvas = document.getElementById('badgeChart');
      const badgeFallback = document.getElementById('badgeChartFallback');

      if (badgeCanvas) {
        if (window.myCharts.badge) {
          window.myCharts.badge.destroy();
        }

        const badgeMap = {};
        appState.talents.forEach(t => {
          if (t.badge) badgeMap[t.badge] = (badgeMap[t.badge] || 0) + 1;
        });

        const labels = Object.keys(badgeMap);
        const values = Object.values(badgeMap);

        if (labels.length === 0) {
          badgeFallback.classList.remove('hidden');
        } else {
          badgeFallback.classList.add('hidden');
          window.myCharts.badge = new Chart(badgeCanvas.getContext('2d'), {
            type: 'bar',
            data: {
              labels: labels,
              datasets: [{
                label: 'Jumlah Talent',
                data: values,
                backgroundColor: 'rgba(91, 91, 214, 0.85)',
                hoverBackgroundColor: 'rgba(91, 91, 214, 1)',
                borderRadius: 8,
                barThickness: 30
              }]
            },
            options: {
              responsive: true,
              maintainAspectRatio: false,
              plugins: {
                legend: { display: false }
              },
              scales: {
                y: {
                  beginAtZero: true,
                  ticks: { stepSize: 1, font: { family: 'Inter' } },
                  grid: { color: 'rgba(0,0,0,0.05)' }
                },
                x: {
                  grid: { display: false },
                  ticks: { font: { family: 'Inter' } }
                }
              }
            }
          });
        }
      }

    } catch (chartError) {
      console.error("Gagal menggambar grafik: ", chartError);
    }
  }

  // 13. Sistem Notifikasi Toast Interaktif
  function showToast(message, iconName = "check", color = "emerald") {
    const toast = document.getElementById('toast');
    const toastIcon = document.getElementById('toastIcon');
    const toastMsg = document.getElementById('toastMsg');

    toastMsg.innerText = message;
    toastIcon.className = `p-1.5 bg-${color}-500 text-white rounded-lg`;
    toastIcon.innerHTML = `<i data-lucide="${iconName}" class="w-4 h-4"></i>`;
    lucide.createIcons();

    toast.classList.remove('translate-y-20', 'opacity-0');
    toast.classList.add('translate-y-0', 'opacity-100');

    setTimeout(() => {
      toast.classList.add('translate-y-20', 'opacity-0');
      toast.classList.remove('translate-y-0', 'opacity-100');
    }, 4000);
  }
</script>

company.html

<!-- MODAL: DAFTAR / EDIT PERUSAHAAN MITRA -->
<div id="modalCompany" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center pointer-events-none opacity-0 transition-all duration-300">
  <div class="bg-white w-full max-w-xl mx-4 rounded-3xl shadow-2xl overflow-hidden transform scale-95 transition-all duration-300 max-h-[90vh] flex flex-col">
    <div class="p-6 bg-gradient-to-r from-emerald-600 to-teal-600 text-white flex justify-between items-center">
      <div>
        <h3 id="companyModalTitle" class="font-poppins font-bold text-lg">Daftarkan Perusahaan Mitra</h3>
        <p class="text-xs text-emerald-100">Hubungkan industri Anda dengan ribuan lulusan vokasi produktif.</p>
      </div>
      <button id="btnCloseCompModal" class="p-2 bg-white/10 hover:bg-white/20 rounded-xl transition text-white">
        <i data-lucide="x" class="w-5 h-5"></i>
      </button>
    </div>
   
    <form id="companyForm" class="p-6 overflow-y-auto space-y-4 flex-1">
      <!-- Hidden Input untuk menampung ID saat proses Update -->
      <input type="hidden" name="company_id" id="compFormId">
      <input type="hidden" name="created_at" id="compFormCreatedAt">

      <div class="space-y-1">
        <label class="text-xs font-semibold text-slate-500">Nama Perusahaan / Instansi</label>
        <input type="text" name="nama_perusahaan" id="compFormNama" placeholder="Contoh: PT Kreatif Digital Nusantara" required class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500/20 text-sm">
      </div>

      <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div class="space-y-1">
          <label class="text-xs font-semibold text-slate-500">Email Representatif HR</label>
          <input type="email" name="email" id="compFormEmail" placeholder="hr@perusahaan.com" required class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500/20 text-sm">
        </div>
        <div class="space-y-1">
          <label class="text-xs font-semibold text-slate-500">Sektor Industri</label>
          <input type="text" name="industri" id="compFormIndustri" placeholder="Contoh: Software House, Creative Agency" required class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500/20 text-sm">
        </div>
      </div>

      <div class="space-y-1">
        <label class="text-xs font-semibold text-slate-500">Lokasi Kantor / Wilayah Kerja</label>
        <input type="text" name="lokasi" id="compFormLokasi" placeholder="Contoh: Jakarta Selatan, DKI Jakarta / Remote" required class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500/20 text-sm">
      </div>

      <div class="space-y-1">
        <label class="text-xs font-semibold text-slate-500">URL Logo Perusahaan</label>
        <input type="url" name="logo_url" id="compFormLogo" placeholder="https://contoh-url.com/logo.png" class="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500/20 text-sm">
        <p class="text-[10px] text-slate-400">Kosongkan untuk menggunakan avatar logo default.</p>
      </div>

      <div class="pt-4 border-t border-slate-100 flex justify-end gap-3">
        <button type="button" id="btnCancelCompForm" class="px-4 py-2 bg-slate-100 hover:bg-slate-200 rounded-xl text-slate-600 font-medium text-sm transition">Batalkan</button>
        <button type="submit" id="btnSubmitCompany" class="px-5 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-xl text-sm transition flex items-center gap-2 shadow-lg shadow-emerald-100">
          <i data-lucide="save" class="w-4 h-4"></i> Simpan Instansi
        </button>
      </div>
    </form>
  </div>
</div>


Tidak ada komentar:

Posting Komentar

MPA - Bukti Pendaftaran

  https://script.google.com/macros/s/AKfycbw1NyiOCuf1kvPEHau3viii_943iUX5CTQJ3hQRM97ng_BMNLsAITQzjUKmp25TYBvx/exec https://drive.google.com/...