feat: inital commit project
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# E-recruitment
|
||||
|
||||
Web sederhana untuk manajemen rekrutmen karyawan: data pelamar, status rekrutmen,
|
||||
berkas (CV, hasil test, portofolio), dan kirim WhatsApp memakai template pesan formal —
|
||||
termasuk reminder jadwal interview.
|
||||
|
||||
Aplikasi ini **murni HTML + CSS + JavaScript** (tanpa backend, tanpa build tool).
|
||||
Cukup buka `index.html` di browser.
|
||||
|
||||
## Cara Pakai
|
||||
|
||||
1. Buka `index.html` di browser (Chrome/Edge/Firefox).
|
||||
2. Klik **+ Tambah Pelamar**, isi data, simpan.
|
||||
3. Klik **Reminder Pelamar** untuk mengirim reminder jadwal interview ke pelamar
|
||||
(atau **Reminder Pewawancara** dari modal Detail untuk mengingatkan pewawancara).
|
||||
4. Atur nama HR & perusahaan di **Pengaturan**.
|
||||
5. Edit template pesan di **Template Pesan**.
|
||||
6. **Export** data secara berkala sebagai cadangan (file JSON).
|
||||
|
||||
## Halaman
|
||||
|
||||
| Halaman | Fungsi |
|
||||
|---|---|
|
||||
| `index.html` | Daftar pelamar, ringkasan, form, detail, reminder, berkas |
|
||||
| `template.html` | Kelola template pesan WhatsApp |
|
||||
| `berkas.html` | Lihat semua berkas pelamar |
|
||||
| `pengaturan.html` | Data perusahaan/HR, status, export/import/reset |
|
||||
|
||||
## Status Rekrutmen
|
||||
|
||||
Baru → Screening → Interview → Diterima / Ditolak / Gagal–Tidak Melanjutkan.
|
||||
Status bisa diubah/ditambah di halaman Pengaturan.
|
||||
|
||||
## Berkas (CV / Hasil Test / Portofolio)
|
||||
|
||||
Dikelola di halaman **Berkas** (juga tersedia di modal **Detail** pelamar):
|
||||
|
||||
1. Klik **Upload** → pilih file (PDF/DOC/gambar, maks. 5 MB), atau
|
||||
2. Klik **Link** → tempel URL (Google Drive, dll).
|
||||
3. **Lihat** untuk membuka, **Hapus** untuk membuang.
|
||||
|
||||
Penyimpanan: **IndexedDB**. Jika IndexedDB diblokir (mis. dibuka via `file://`),
|
||||
berkas otomatis disimpan di localStorage sebagai base64 dengan batas 2 MB per file.
|
||||
|
||||
## Reminder
|
||||
|
||||
Ada dua tombol reminder (keduanya berisi preview sebelum dikirim):
|
||||
|
||||
- **Reminder Pelamar** — dikirim ke nomor pelamar (tombol di tabel & modal Detail).
|
||||
- **Reminder Pewawancara** — dikirim ke nomor pewawancara (tombol di modal Detail).
|
||||
|
||||
### Menyiapkan Reminder Pewawancara
|
||||
|
||||
1. Isi **Nama pewawancara** dan **No. WA pewawancara** saat menambah/mengedit pelamar.
|
||||
2. Buka **Detail** pelamar → klik **Reminder Pewawancara**.
|
||||
3. Pilih template jenis *Reminder Interview (Pewawancara)* → pesan berisi data
|
||||
kandidat + jadwal akan terbuka di WhatsApp pewawancara.
|
||||
|
||||
Untuk mengubah isi pesan reminder, edit template di menu **Template Pesan**.
|
||||
|
||||
## Export & Import Excel
|
||||
|
||||
Data pelamar bisa di-export ke **.xlsx** dan di-import kembali dari **.xlsx / .xls / .csv**.
|
||||
|
||||
- **Export Excel** — tombol di toolbar halaman Pelamar, atau di Pengaturan.
|
||||
- **Import Excel** — pilih file; baris dengan Nama & No WhatsApp valid akan ditambahkan.
|
||||
Baris tidak valid dilewati dan dilaporkan.
|
||||
- **Unduh Template Excel** — tombol di halaman Pengaturan untuk format kolom yang benar.
|
||||
|
||||
Kolom yang didukung: `Nama`, `No WhatsApp`, `Email`, `Posisi`, `Status`, `Sumber`,
|
||||
`Domisili`, `Gaji Ekspektasi`, `Tanggal Melamar`, `Catatan`, `Tanggal Interview`,
|
||||
`Jam Interview`, `Metode`, `Lokasi/Link`, `Pewawancara`, `No WA Pewawancara`.
|
||||
Nama kolom tidak harus persis — ada alias (mis. `No HP`, `No WA`).
|
||||
|
||||
Catatan tanggal: format `YYYY-MM-DD` paling aman; format `dd/mm/yyyy` juga dikenali.
|
||||
|
||||
## Placeholder Template
|
||||
|
||||
`{{nama}}`, `{{posisi}}`, `{{perusahaan}}`, `{{status}}`, `{{tanggal}}`, `{{hari}}`,
|
||||
`{{jam}}`, `{{metode}}`, `{{lokasi}}`, `{{pewawancara}}`, `{{hr}}`, `{{jabatan_hr}}`
|
||||
|
||||
## Penyimpanan Data
|
||||
|
||||
- Data utama: **localStorage** browser.
|
||||
- Berkas upload: **IndexedDB** (maks. 5 MB per file; fallback localStorage 2 MB).
|
||||
- Backup: **Export/Import JSON** (lengkap, termasuk berkas) atau **Export/Import Excel** (data pelamar saja).
|
||||
|
||||
> Data tersimpan di browser. Bersihkan cache berisiko menghapus data — selalu
|
||||
> lakukan **Export** berkala, atau buka lewat server lokal agar konsisten.
|
||||
|
||||
## Menjalankan via server lokal (opsional, disarankan)
|
||||
|
||||
```bash
|
||||
# Python
|
||||
python -m http.server 8000
|
||||
|
||||
# Node
|
||||
npx serve .
|
||||
```
|
||||
|
||||
Lalu buka `http://localhost:8000`.
|
||||
|
||||
Dokumen spesifikasi lengkap ada di `agen.md`.
|
||||
@@ -0,0 +1,358 @@
|
||||
# agen.md — Web Rekrutmen Karyawan (VC-Final)
|
||||
|
||||
> Dokumen acuan kerja agen (spec + konvensi + roadmap).
|
||||
> Status: **SELESAI (MVP) — versi web sederhana sudah dibangun.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Ringkasan Proyek
|
||||
|
||||
Web **sederhana** untuk membantu HR mengelola data pelamar, memantau **status
|
||||
rekrutmen**, menyimpan **berkas pelamar (CV, hasil test, portofolio)**, serta
|
||||
menghubungi pelamar via **WhatsApp** memakai **template formal dari HR** —
|
||||
termasuk **reminder jadwal interview**.
|
||||
|
||||
Nama perusahaan/aplikasi: **E-recruitment**.
|
||||
|
||||
Inti nilai: **satu klik dari data pelamar → WhatsApp terbuka dengan pesan formal
|
||||
yang sudah terisi data pelamar, tinggal tekan kirim.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Keputusan yang Sudah Ditetapkan
|
||||
|
||||
| # | Topik | Keputusan |
|
||||
|---|---|---|
|
||||
| 1 | Bentuk aplikasi | Web sederhana, tanpa backend, tanpa login |
|
||||
| 2 | Penyimpanan | **localStorage** + fitur **Export (unduh JSON)** dan Import |
|
||||
| 3 | Pengiriman WA | **Manual** — user klik tombol, `wa.me` terbuka, user menekan "Kirim" |
|
||||
| 4 | Status | Baru, Screening, Interview, Diterima, Ditolak, Gagal/Tidak Melanjutkan |
|
||||
| 5 | Isi pesan | Formal, template dari HR, bisa diedit HR |
|
||||
| 6 | Pengguna | HR saja (single user) |
|
||||
| 7 | Struktur file | Dipisah per halaman |
|
||||
| 8 | Nama perusahaan | **E-recruitment** |
|
||||
| 9 | Fitur tambahan | Reminder WA interview + berkas CV/hasil test/portofolio |
|
||||
|
||||
---
|
||||
|
||||
## 3. Ruang Lingkup
|
||||
|
||||
### 3.1 Termasuk
|
||||
1. CRUD data pelamar.
|
||||
2. Status rekrutmen + riwayat perubahan status.
|
||||
3. Jadwal interview (tanggal, jam, metode, lokasi/link).
|
||||
4. Dua tombol reminder: **Reminder Pelamar** (ke nomor pelamar) dan
|
||||
**Reminder Pewawancara** (ke nomor pewawancara).
|
||||
5. Tombol **Reminder Interview** → pesan WA jadwal interview.
|
||||
6. Berkas pelamar: **CV, hasil test, portofolio** (upload file dan/atau link).
|
||||
7. Manajemen template pesan formal.
|
||||
8. Pencarian & filter (nama, posisi, status, sumber).
|
||||
9. Ringkasan angka per status.
|
||||
10. **Export & Import Excel (.xlsx/.xls/.csv)** untuk data pelamar.
|
||||
11. **Export & Import JSON** (cadangan lengkap termasuk berkas).
|
||||
|
||||
### 3.2 Tidak termasuk
|
||||
- Login / multi-user / hak akses.
|
||||
- Kirim WA otomatis dari server (butuh WA Business API berbayar).
|
||||
- Payroll, absensi, kontrak kerja.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alur Utama (User Flow)
|
||||
|
||||
**A. Kelola pelamar**
|
||||
1. HR buka web → daftar pelamar + ringkasan.
|
||||
2. Tambah/edit/hapus data pelamar, atur status.
|
||||
3. Lampirkan CV, hasil test, portofolio.
|
||||
|
||||
**B. Hubungi via WhatsApp**
|
||||
1. HR klik tombol **WhatsApp** pada baris pelamar.
|
||||
2. Muncul dialog: pilih template (otomatis sesuai status) + **preview pesan**.
|
||||
3. Klik **Buka WhatsApp** → tab/app WA terbuka dengan pesan terisi.
|
||||
4. HR menekan **Kirim**.
|
||||
|
||||
**C. Reminder interview**
|
||||
1. Untuk pelamar berstatus **Interview**, HR mengisi jadwal interview.
|
||||
2. HR klik tombol **Reminder Interview**.
|
||||
3. Pesan otomatis berisi hari/tanggal, jam, dan metode interview (dari data pelamar).
|
||||
4. HR kirim lewat WhatsApp.
|
||||
|
||||
**D. Export**
|
||||
1. HR klik **Export** → browser mengunduh file `e-recruitment-backup-YYYYMMDD.json`.
|
||||
2. File bisa dipakai untuk pindah perangkat atau cadangan.
|
||||
|
||||
---
|
||||
|
||||
## 5. Penyimpanan Data
|
||||
|
||||
- Data utama di **localStorage** (key berversi, mis. `erecruitment.v1`).
|
||||
- **Export** JSON berisi seluruh data: pelamar, riwayat status, template, pengaturan.
|
||||
- **Import** JSON untuk memulihkan/menggabungkan data.
|
||||
- Berkas (CV/hasil test/portofolio): lihat bagian 6.4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fitur Detail
|
||||
|
||||
### 6.1 Data Pelamar
|
||||
| Field | Wajib | Keterangan |
|
||||
|---|---|---|
|
||||
| Nama lengkap | ✅ | |
|
||||
| Nomor WhatsApp | ✅ | dinormalisasi ke format 62... |
|
||||
| Posisi yang dilamar | ✅ | |
|
||||
| Status | ✅ | default: **Baru** |
|
||||
| Email | – | |
|
||||
| Domisili | – | |
|
||||
| Sumber pelamar | – | Iklan / Referral / Walk-in / Job Portal / Lainnya |
|
||||
| Tanggal melamar | – | default hari ini |
|
||||
| Catatan HR | – | internal, tidak dikirim ke pelamar |
|
||||
|
||||
### 6.2 Jadwal Interview
|
||||
| Field | Keterangan |
|
||||
|---|---|
|
||||
| Tanggal interview | dipakai untuk {{tanggal}} & {{hari}} |
|
||||
| Jam interview | dipakai untuk {{jam}} |
|
||||
| Metode | Online / Offline |
|
||||
| Lokasi / Link meeting | alamat kantor atau link (Zoom/Meet) |
|
||||
| Nama pewawancara | opsional, untuk catatan |
|
||||
| No. WA pewawancara | dipakai untuk Reminder Pewawancara |
|
||||
|
||||
### 6.3 Status Rekrutmen
|
||||
1. **Baru** — baru masuk (default)
|
||||
2. **Screening** — sedang ditinjau
|
||||
3. **Interview** — dijadwalkan/dalam wawancara
|
||||
4. **Diterima** — lolos / direkrut
|
||||
5. **Ditolak** — tidak lolos seleksi
|
||||
6. **Gagal / Tidak Melanjutkan** — tidak hadir / mengundurkan diri / berhenti di tengah proses
|
||||
|
||||
- Daftar status bersifat data (HR bisa ubah).
|
||||
- Setiap perubahan status dicatat: status lama, status baru, waktu.
|
||||
|
||||
### 6.4 Berkas Pelamar
|
||||
Tiga slot berkas: **CV**, **Hasil Test**, **Portofolio**.
|
||||
|
||||
Setiap slot bisa diisi salah satu (atau keduanya):
|
||||
- **Link/URL** — mis. Google Drive. Paling ringan, direkomendasikan.
|
||||
- **Upload file** — PDF/gambar, disimpan sebagai data di browser.
|
||||
|
||||
> Catatan teknis: localStorage hanya ±5 MB, sehingga file besar tidak muat.
|
||||
> Rencana: file upload disimpan di **IndexedDB** (kapasitas jauh lebih besar),
|
||||
> sedangkan metadata & link tetap di localStorage. Saat **Export**, isi file
|
||||
> disertakan sebagai base64 (dengan peringatan bila ukuran backup besar).
|
||||
> Tombol **Lihat/Unduh** dan **Hapus** tersedia untuk tiap berkas.
|
||||
|
||||
### 6.5 WhatsApp Direct + Template
|
||||
- Format link: `https://wa.me/<nomor>?text=<pesan ter-encode>`
|
||||
- Normalisasi nomor:
|
||||
- `08xxxxxxxxxx` → `628xxxxxxxxxx`
|
||||
- `+62...` → `62...`
|
||||
- `8xxxxxxxxx` → `628xxxxxxxxx`
|
||||
- Buang spasi, `-`, `.`, `(`, `)`.
|
||||
- Placeholder yang didukung:
|
||||
`{{nama}}`, `{{posisi}}`, `{{perusahaan}}`, `{{status}}`, `{{tanggal}}`,
|
||||
`{{hari}}`, `{{jam}}`, `{{metode}}`, `{{lokasi}}`, `{{pewawancara}}`, `{{hr}}`, `{{jabatan_hr}}`
|
||||
- Template otomatis terpilih sesuai status; HR bisa ganti template lain.
|
||||
- Selalu ada **preview pesan** sebelum dibuka di WhatsApp.
|
||||
- Buka di tab baru (`target="_blank"` + `rel="noopener"`).
|
||||
|
||||
### 6.6 Manajemen Template
|
||||
- CRUD template formal: nama, isi pesan, status terkait (opsional), aktif/nonaktif.
|
||||
- Tombol sisip placeholder.
|
||||
- Data perusahaan & nama HR diatur di menu **Pengaturan**.
|
||||
|
||||
### 6.7 Pencarian, Filter, Ringkasan
|
||||
- Cari: nama / nomor HP / posisi.
|
||||
- Filter: status, posisi, sumber.
|
||||
- Urutkan: tanggal melamar terbaru / nama.
|
||||
- Kartu ringkasan: total pelamar & jumlah per status.
|
||||
|
||||
---
|
||||
|
||||
## 7. Template Pesan Formal (draft awal)
|
||||
|
||||
> Bahasa formal, sopan, mewakili HR. Ditandatangani nama HR & E-recruitment.
|
||||
|
||||
**T1 — Konfirmasi lamaran (Status: Baru)**
|
||||
> Yth. {{nama}},
|
||||
>
|
||||
> Terima kasih atas ketertarikan Anda melamar posisi {{posisi}} di {{perusahaan}}.
|
||||
> Lamaran Anda telah kami terima dan akan kami proses sesuai tahapan yang berlaku.
|
||||
> Kami akan menghubungi Anda kembali apabila terdapat pembaruan.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
**T2 — Undangan interview (Status: Interview)**
|
||||
> Yth. {{nama}},
|
||||
>
|
||||
> Sehubungan dengan lamaran Anda untuk posisi {{posisi}} di {{perusahaan}}, kami
|
||||
> mengundang Anda untuk mengikuti tahap wawancara pada hari {{hari}}, {{tanggal}}
|
||||
> pukul {{jam}} ({{metode}}).
|
||||
> Mohon konfirmasi ketersediaan Anda melalui pesan ini.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
**T3 — Reminder interview (Status: Interview)** ⭐
|
||||
> Yth. {{nama}},
|
||||
>
|
||||
> Kami mengingatkan kembali jadwal wawancara Anda untuk posisi {{posisi}} di
|
||||
> {{perusahaan}} pada hari **{{hari}}, {{tanggal}} pukul {{jam}}** ({{metode}}).
|
||||
> Lokasi/tautan: {{lokasi}}.
|
||||
> Mohon hadir tepat waktu dan konfirmasi kehadiran Anda melalui pesan ini.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
**T3b — Reminder interview ke Pewawancara (Jenis: Pewawancara)** ⭐
|
||||
> Yth. {{pewawancara}},
|
||||
>
|
||||
> Kami mengingatkan jadwal wawancara untuk kandidat berikut:
|
||||
> Nama: {{nama}}
|
||||
> Posisi: {{posisi}}
|
||||
> Waktu: {{hari}}, {{tanggal}} pukul {{jam}} ({{metode}})
|
||||
> Lokasi/tautan: {{lokasi}}
|
||||
>
|
||||
> Mohon konfirmasi kesediaan Anda. Terima kasih.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
**T4 — Diterima (Status: Diterima)**
|
||||
> Yth. {{nama}},
|
||||
>
|
||||
> Dengan senang hati kami sampaikan bahwa Anda **diterima** untuk posisi {{posisi}}
|
||||
> di {{perusahaan}}. Kami akan mengirimkan informasi lebih lanjut mengenai proses
|
||||
> selanjutnya.
|
||||
> Mohon konfirmasi kesediaan Anda melalui pesan ini.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
**T5 — Ditolak (Status: Ditolak)**
|
||||
> Yth. {{nama}},
|
||||
>
|
||||
> Terima kasih atas waktu dan minat Anda melamar posisi {{posisi}} di {{perusahaan}}.
|
||||
> Setelah melalui proses evaluasi, dengan berat hati kami sampaikan bahwa lamaran
|
||||
> Anda belum dapat kami lanjutkan pada kesempatan ini.
|
||||
> Kami menyimpan data Anda untuk peluang di masa mendatang.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
**T6 — Gagal / Tidak Melanjutkan (Status: Gagal / Tidak Melanjutkan)**
|
||||
> Yth. {{nama}},
|
||||
>
|
||||
> Kami menindaklanjuti proses lamaran Anda untuk posisi {{posisi}} di {{perusahaan}}.
|
||||
> Berdasarkan catatan kami, proses seleksi Anda tidak dapat dilanjutkan.
|
||||
> Apabila terdapat kekeliruan, silakan menghubungi kami kembali.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
**T7 — Follow-up (opsional)**
|
||||
> Yth. {{nama}},
|
||||
>
|
||||
> Kami ingin menindaklanjuti proses lamaran Anda untuk posisi {{posisi}} di
|
||||
> {{perusahaan}}. Mohon berkenan memberikan konfirmasi atas pesan ini.
|
||||
>
|
||||
> Hormat kami,
|
||||
> {{hr}}
|
||||
> {{perusahaan}}
|
||||
|
||||
---
|
||||
|
||||
## 8. Rencana Teknologi
|
||||
|
||||
**Web statis murni: HTML + CSS + JavaScript. Tanpa framework, tanpa backend.**
|
||||
|
||||
- Tanpa build tool / `npm install` — cukup buka `index.html` di browser.
|
||||
- Data di localStorage; berkas di IndexedDB; backup via Export JSON.
|
||||
- Bisa di-host gratis (GitHub Pages/Netlify) atau dipakai lokal.
|
||||
|
||||
---
|
||||
|
||||
## 9. Struktur Folder
|
||||
|
||||
```
|
||||
VC-Final/
|
||||
├─ agen.md
|
||||
├─ README.md
|
||||
├─ index.html # daftar pelamar + ringkasan + form
|
||||
├─ template.html # manajemen template pesan
|
||||
├─ berkas.html # (opsional) daftar/kelola berkas pelamar
|
||||
├─ pengaturan.html # data perusahaan & HR
|
||||
└─ assets/
|
||||
├─ css/style.css
|
||||
└─ js/
|
||||
├─ store.js # localStorage + IndexedDB + export/import
|
||||
├─ phone.js # normalisasi nomor HP
|
||||
├─ whatsapp.js # render template + build wa.me
|
||||
├─ ui.js # komponen tabel/kartu, form, modal, toast
|
||||
├─ app.js # logika halaman pelamar
|
||||
├─ templates.js # logika halaman template
|
||||
└─ settings.js # logika halaman pengaturan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Konvensi
|
||||
|
||||
- **Bahasa UI:** Indonesia.
|
||||
- **Penamaan kode:** Inggris; komentar boleh Indonesia.
|
||||
- **Nomor HP:** simpan `phone_raw` + `phone_norm`.
|
||||
- **Template:** teks biasa + placeholder `{{...}}`.
|
||||
- **Tanggal:** simpan ISO, tampilkan format Indonesia; ada helper hari (Senin, dst.).
|
||||
- **UI:** mobile-friendly, tombol WhatsApp besar & jelas, warna status berbeda.
|
||||
- **Commit:** Conventional Commits (`feat:`, `fix:`, `docs:`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Roadmap
|
||||
|
||||
- [x] **M0** — Diskusi & finalisasi spec
|
||||
- [x] **M1** — Struktur file + store (localStorage/IndexedDB) + data status & template default
|
||||
- [x] **M2** — CRUD pelamar + normalisasi no HP + validasi
|
||||
- [x] **M3** — Berkas pelamar (CV, hasil test, portofolio): link + upload, lihat, hapus
|
||||
- [x] **M4** — Tombol WhatsApp + pilih template + preview + render placeholder
|
||||
- [x] **M5** — Jadwal interview + tombol Reminder Interview
|
||||
- [x] **M6** — Manajemen template + halaman pengaturan (nama HR, E-recruitment)
|
||||
- [x] **M7** — Pencarian, filter, ringkasan
|
||||
- [x] **M8** — Export/Import JSON + polish UI
|
||||
- [x] **M9** — Upload berkas (IndexedDB + fallback) & Reminder Pewawancara
|
||||
- [x] **M10** — Export/Import Excel (.xlsx/.xls/.csv) + template Excel
|
||||
- [ ] (lanjutan) Login, kirim WA otomatis
|
||||
|
||||
---
|
||||
|
||||
## 12. Catatan / Asumsi
|
||||
|
||||
1. Nama HR & jabatan dikosongkan dulu, diisi HR lewat halaman **Pengaturan**
|
||||
(dipakai di tanda tangan template).
|
||||
2. Format tanggal Indonesia: mis. `Senin, 22 September 2025`.
|
||||
3. Berkas upload dibatasi ukuran (disarankan ≤ 5 MB per file) dan tipe
|
||||
PDF/Gambar; link eksternal tidak dibatasi.
|
||||
4. Export menyertakan berkas yang di-upload, sehingga ukuran file backup bisa
|
||||
membesar — beri peringatan bila melebihi ambang tertentu.
|
||||
|
||||
---
|
||||
|
||||
## 13. Definisi Selesai (DoD)
|
||||
|
||||
- HR bisa menambah, mengedit, menghapus, dan melihat daftar pelamar.
|
||||
- Status bisa diubah dan perubahannya tercatat.
|
||||
- Tombol **Reminder Pelamar** dan **Reminder Pewawancara** membuka `wa.me` dengan
|
||||
nomor yang benar & pesan formal terisi otomatis.
|
||||
- Tombol Reminder Interview menghasilkan pesan jadwal (hari, tanggal, jam, metode).
|
||||
- Berkas CV, hasil test, dan portofolio dapat diunggah/ditautkan, dilihat, dan dihapus.
|
||||
- Template bisa diedit HR tanpa menyentuh kode.
|
||||
- Pencarian, filter status, dan ringkasan berfungsi.
|
||||
- Export & Import data berfungsi.
|
||||
- Tampilan rapi di desktop dan HP.
|
||||
@@ -0,0 +1,411 @@
|
||||
/* style.css — E-recruitment (tema modern biru–hijau) */
|
||||
:root {
|
||||
--blue: #2563eb;
|
||||
--blue-dark: #1d4ed8;
|
||||
--blue-soft: #eff6ff;
|
||||
--green: #10b981;
|
||||
--green-dark: #059669;
|
||||
--green-soft: #ecfdf5;
|
||||
--grad: linear-gradient(135deg, #2563eb 0%, #10b981 100%);
|
||||
--grad-soft: linear-gradient(135deg, #eff6ff 0%, #ecfdf5 100%);
|
||||
--ink: #0f172a;
|
||||
--muted: #64748b;
|
||||
--line: #e5eaf1;
|
||||
--card: #ffffff;
|
||||
--bg: #f6f9fc;
|
||||
--red: #dc2626;
|
||||
--amber: #d97706;
|
||||
--radius: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, .05);
|
||||
--shadow: 0 4px 16px rgba(15, 23, 42, .07);
|
||||
--shadow-lg: 0 18px 48px rgba(15, 23, 42, .20);
|
||||
--ring: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
background:
|
||||
radial-gradient(1100px 520px at -10% -12%, #dbeafe 0%, transparent 55%),
|
||||
radial-gradient(900px 480px at 112% -6%, #d1fae5 0%, transparent 52%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
h1, h2, h3, h4 { margin: 0 0 .5rem; line-height: 1.25; letter-spacing: -.01em; }
|
||||
p { margin: 0 0 .75rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.center { text-align: center; }
|
||||
.right { text-align: right; }
|
||||
.mt1 { margin-top: 8px; }
|
||||
.mt2 { margin-top: 18px; }
|
||||
.mb2 { margin-bottom: 16px; }
|
||||
.hidden { display: none !important; }
|
||||
code { background: var(--grad-soft); border: 1px solid var(--line); padding: 1px 7px; border-radius: 6px; font-size: 12px; }
|
||||
|
||||
/* ---------- Navbar ---------- */
|
||||
.navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, .82);
|
||||
backdrop-filter: saturate(160%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(160%) blur(12px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.navbar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: var(--grad);
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; color: var(--ink); }
|
||||
.logo-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 11px;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 19px;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, .32);
|
||||
flex: none;
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 16.5px;
|
||||
font-weight: 800;
|
||||
background: var(--grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.nav-links { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.nav-links a {
|
||||
color: #475569;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
transition: background .16s, color .16s, box-shadow .16s, transform .16s;
|
||||
}
|
||||
.nav-links a:hover { background: var(--blue-soft); color: var(--blue-dark); }
|
||||
.nav-links a.active {
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, .28);
|
||||
}
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.container { max-width: 1160px; margin: 0 auto; padding: 26px 20px 70px; }
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-head h1 { font-size: 24px; font-weight: 800; }
|
||||
.page-head h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 46px;
|
||||
height: 4px;
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--grad);
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
.card + .card { margin-top: 18px; }
|
||||
.card > h3 { font-size: 17px; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid.cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
/* ---------- Summary ---------- */
|
||||
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(158px, 1fr)); gap: 14px; margin-bottom: 20px; }
|
||||
.stat {
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 16px 16px 22px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
transition: transform .18s, box-shadow .18s;
|
||||
}
|
||||
.stat:hover { transform: translateY(-3px); box-shadow: 0 12px 26px rgba(15, 23, 42, .12); }
|
||||
.stat::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 5px;
|
||||
background: var(--accent, var(--grad));
|
||||
}
|
||||
.stat .num { font-size: 26px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.stat .lbl { color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||
|
||||
/* ---------- Toolbar ---------- */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, .7);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.toolbar .grow { flex: 1 1 220px; }
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
label { font-weight: 600; font-size: 13px; display: block; margin-bottom: 5px; color: #334155; }
|
||||
input[type="text"], input[type="search"], input[type="url"], input[type="email"],
|
||||
input[type="date"], input[type="time"], input[type="number"], input[type="color"], select, textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input[type="color"] { padding: 4px; height: 42px; }
|
||||
input::placeholder, textarea::placeholder { color: #9aa8bb; }
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--blue);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field .hint { font-size: 12px; color: var(--muted); margin-top: 5px; }
|
||||
.field-error { color: var(--red); font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
padding: 10px 16px;
|
||||
border-radius: 11px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: transform .15s, box-shadow .15s, background .15s, border-color .15s, color .15s;
|
||||
}
|
||||
.btn:hover { background: #f8fafc; border-color: #d5dee9; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--grad);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, .28);
|
||||
}
|
||||
.btn-primary:hover { background: var(--grad); filter: brightness(1.06); transform: translateY(-1px); box-shadow: 0 12px 24px rgba(37, 99, 235, .34); }
|
||||
.btn-wa {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(16, 185, 129, .28);
|
||||
}
|
||||
.btn-wa:hover { filter: brightness(1.06); transform: translateY(-1px); }
|
||||
.btn-warn { background: #fff7ed; border-color: #fed7aa; color: #c2410c; }
|
||||
.btn-warn:hover { background: #ffedd5; border-color: #fdba74; }
|
||||
.btn-danger { background: #fff; border-color: #fecaca; color: var(--red); }
|
||||
.btn-danger:hover { background: #fef2f2; border-color: #fca5a5; }
|
||||
.btn-ghost { background: #fff; }
|
||||
.btn-sm { padding: 6px 11px; font-size: 12px; border-radius: 9px; }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
/* ---------- Table ---------- */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; min-width: 760px; }
|
||||
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th {
|
||||
background: linear-gradient(180deg, #f8fbff, #f1f5f9);
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
tbody tr { transition: background .14s; }
|
||||
tbody tr:hover { background: #f0f9ff; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.empty { padding: 46px 20px; text-align: center; color: var(--muted); background: #fff; border: 1px dashed var(--line); border-radius: var(--radius); }
|
||||
|
||||
/* ---------- Badge ---------- */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, .12);
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, .5);
|
||||
backdrop-filter: blur(3px);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 28px 14px;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: pop .18s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal.lg { max-width: 820px; }
|
||||
@keyframes pop { from { transform: translateY(-10px) scale(.99); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--grad-soft);
|
||||
}
|
||||
.modal-head h3 { margin: 0; font-size: 17px; font-weight: 800; }
|
||||
.modal-x { border: none; background: none; font-size: 26px; line-height: 1; cursor: pointer; color: var(--muted); border-radius: 8px; width: 34px; height: 34px; }
|
||||
.modal-x:hover { background: #fff; color: var(--ink); }
|
||||
.modal-body { padding: 20px; }
|
||||
.modal-foot { padding: 15px 20px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; background: #fafcff; }
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
#toastBox { position: fixed; bottom: 18px; right: 18px; display: flex; flex-direction: column; gap: 10px; z-index: 200; }
|
||||
.toast {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: pop .18s ease-out;
|
||||
max-width: 340px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.toast.ok { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
.toast.err { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
||||
|
||||
/* ---------- Berkas ---------- */
|
||||
.berkas-row { display: flex; align-items: center; gap: 10px; padding: 11px 0; border-bottom: 1px dashed var(--line); flex-wrap: wrap; }
|
||||
.berkas-row:last-child { border-bottom: none; }
|
||||
.berkas-label { width: 112px; font-weight: 700; }
|
||||
.berkas-value { flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
|
||||
.berkas-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.placeholder-btns { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
border: 1px dashed #cbd5e1;
|
||||
background: var(--grad-soft);
|
||||
border-radius: 999px;
|
||||
padding: 4px 11px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
color: #334155;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.chip:hover { border-color: var(--blue); color: var(--blue-dark); background: var(--blue-soft); }
|
||||
|
||||
.preview-box {
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow .18s, transform .18s;
|
||||
}
|
||||
.template-item:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
|
||||
.template-item + .template-item { margin-top: 12px; }
|
||||
.template-item .ti-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.template-item pre { margin: 10px 0 0; white-space: pre-wrap; font-family: inherit; font-size: 13px; color: #334155; background: #f8fafc; border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
||||
|
||||
/* ---------- Scrollbar ---------- */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; background-clip: padding-box; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.grid.cols-4, .grid.cols-3, .grid.cols-2 { grid-template-columns: 1fr; }
|
||||
.page-head h1 { font-size: 20px; }
|
||||
.container { padding: 18px 14px 60px; }
|
||||
.nav-inner { padding: 10px 14px; gap: 12px; }
|
||||
.modal-foot { justify-content: stretch; }
|
||||
.modal-foot .btn { flex: 1 1 auto; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="eg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#2563eb"/>
|
||||
<stop offset="1" stop-color="#10b981"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#eg)"/>
|
||||
<rect x="22" y="17" width="21" height="7" rx="2" fill="#fff"/>
|
||||
<rect x="22" y="28.5" width="15" height="7" rx="2" fill="#fff"/>
|
||||
<rect x="22" y="40" width="21" height="7" rx="2" fill="#fff"/>
|
||||
<rect x="22" y="17" width="7" height="30" rx="2" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,568 @@
|
||||
/* app.js — Halaman daftar pelamar */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (s, r) { return (r || document).querySelector(s); };
|
||||
var filters = { q: '', status: '', posisi: '', sumber: '', sort: 'tanggal-desc' };
|
||||
var SUMBER = ['', 'Iklan', 'Referral', 'Walk-in', 'Job Portal', 'Lainnya'];
|
||||
var MAX_FILE = 5 * 1024 * 1024; // 5 MB
|
||||
var SLOTS = [
|
||||
{ key: 'cv', label: 'CV' },
|
||||
{ key: 'test', label: 'Hasil Test' },
|
||||
{ key: 'portofolio', label: 'Portofolio' }
|
||||
];
|
||||
|
||||
/* ---------------- Init ---------------- */
|
||||
function init() {
|
||||
Store.init();
|
||||
bindEvents();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
fillFilters();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function esc(v) { return UI.escape(v); }
|
||||
|
||||
function statusName(id) {
|
||||
var s = Store.statusById(id);
|
||||
return s ? s.nama : '-';
|
||||
}
|
||||
|
||||
/* ---------------- Summary ---------------- */
|
||||
function renderSummary() {
|
||||
var list = Store.get().candidates;
|
||||
var statuses = Store.sortedStatuses();
|
||||
var html = '<div class="stat" style="--accent:linear-gradient(135deg,#2563eb,#10b981)"><div class="num">' + list.length +
|
||||
'</div><div class="lbl">Total Pelamar</div></div>';
|
||||
statuses.forEach(function (s) {
|
||||
var n = list.filter(function (c) { return c.statusId === s.id; }).length;
|
||||
html += '<div class="stat" style="--accent:' + esc(s.warna) + '">' +
|
||||
'<div class="num">' + n + '</div><div class="lbl">' + esc(s.nama) + '</div></div>';
|
||||
});
|
||||
$('#summary').innerHTML = html;
|
||||
}
|
||||
|
||||
/* ---------------- Filters ---------------- */
|
||||
function fillFilters() {
|
||||
var candidates = Store.get().candidates;
|
||||
|
||||
var statusSel = $('#fStatus');
|
||||
statusSel.innerHTML = '<option value="">Semua Status</option>' +
|
||||
Store.sortedStatuses().map(function (s) {
|
||||
return '<option value="' + esc(s.id) + '"' + (filters.status === s.id ? ' selected' : '') + '>' + esc(s.nama) + '</option>';
|
||||
}).join('');
|
||||
|
||||
var posisiSet = {};
|
||||
candidates.forEach(function (c) { if (c.posisi) posisiSet[c.posisi] = 1; });
|
||||
var posisiList = Object.keys(posisiSet).sort();
|
||||
$('#fPosisi').innerHTML = '<option value="">Semua Posisi</option>' +
|
||||
posisiList.map(function (p) {
|
||||
return '<option value="' + esc(p) + '"' + (filters.posisi === p ? ' selected' : '') + '>' + esc(p) + '</option>';
|
||||
}).join('');
|
||||
|
||||
$('#fSumber').innerHTML = '<option value="">Semua Sumber</option>' +
|
||||
SUMBER.slice(1).map(function (p) {
|
||||
return '<option value="' + esc(p) + '"' + (filters.sumber === p ? ' selected' : '') + '>' + esc(p) + '</option>';
|
||||
}).join('');
|
||||
|
||||
$('#fSearch').value = filters.q;
|
||||
$('#fSort').value = filters.sort;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var list = Store.get().candidates.slice();
|
||||
var q = filters.q.trim().toLowerCase();
|
||||
if (q) {
|
||||
var digits = q.replace(/\D/g, '');
|
||||
list = list.filter(function (c) {
|
||||
return (c.nama || '').toLowerCase().indexOf(q) >= 0 ||
|
||||
(c.posisi || '').toLowerCase().indexOf(q) >= 0 ||
|
||||
(c.email || '').toLowerCase().indexOf(q) >= 0 ||
|
||||
(c.phoneRaw || '').toLowerCase().indexOf(q) >= 0 ||
|
||||
(digits !== '' && (c.phoneNorm || '').indexOf(digits) >= 0);
|
||||
});
|
||||
}
|
||||
if (filters.status) list = list.filter(function (c) { return c.statusId === filters.status; });
|
||||
if (filters.posisi) list = list.filter(function (c) { return c.posisi === filters.posisi; });
|
||||
if (filters.sumber) list = list.filter(function (c) { return c.sumber === filters.sumber; });
|
||||
|
||||
list.sort(function (a, b) {
|
||||
if (filters.sort === 'nama-asc') return (a.nama || '').localeCompare(b.nama || '', 'id');
|
||||
var da = a.tanggalMelamar || '', db = b.tanggalMelamar || '';
|
||||
return filters.sort === 'tanggal-asc' ? da.localeCompare(db) : db.localeCompare(da);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ---------------- List ---------------- */
|
||||
function renderList() {
|
||||
var list = applyFilters();
|
||||
var tbody = $('#tbody');
|
||||
var empty = $('#emptyState');
|
||||
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '';
|
||||
var adaData = Store.get().candidates.length > 0;
|
||||
empty.innerHTML = adaData
|
||||
? 'Tidak ada pelamar yang cocok dengan pencarian/filter.'
|
||||
: 'Belum ada data pelamar. Klik <strong>+ Tambah Pelamar</strong> untuk memulai.';
|
||||
empty.classList.remove('hidden');
|
||||
$('.table-wrap').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
$('.table-wrap').classList.remove('hidden');
|
||||
|
||||
tbody.innerHTML = list.map(function (c) {
|
||||
var st = Store.statusById(c.statusId);
|
||||
var phone = c.phoneNorm ? Phone.formatDisplay(c.phoneNorm) : (c.phoneRaw || '-');
|
||||
return '<tr>' +
|
||||
'<td><strong>' + esc(c.nama) + '</strong>' +
|
||||
(c.email ? '<div class="muted small">' + esc(c.email) + '</div>' : '') + '</td>' +
|
||||
'<td>' + esc(c.posisi) + '</td>' +
|
||||
'<td>' + esc(phone) + '</td>' +
|
||||
'<td>' + UI.badge(st) + '</td>' +
|
||||
'<td>' + esc(UI.formatDate(c.tanggalMelamar)) + '</td>' +
|
||||
'<td><div class="actions">' +
|
||||
'<button class="btn btn-warn btn-sm" data-act="reminder" data-id="' + c.id + '">Reminder Pelamar</button>' +
|
||||
'<button class="btn btn-ghost btn-sm" data-act="detail" data-id="' + c.id + '">Detail</button>' +
|
||||
'<button class="btn btn-ghost btn-sm" data-act="edit" data-id="' + c.id + '">Edit</button>' +
|
||||
'<button class="btn btn-danger btn-sm" data-act="del" data-id="' + c.id + '">Hapus</button>' +
|
||||
'</div></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ---------------- Form ---------------- */
|
||||
function statusOptions(selected) {
|
||||
return Store.sortedStatuses().map(function (s) {
|
||||
return '<option value="' + esc(s.id) + '"' + (s.id === selected ? ' selected' : '') + '>' + esc(s.nama) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function sumberOptions(selected) {
|
||||
return SUMBER.map(function (s) {
|
||||
return '<option value="' + esc(s) + '"' + (s === selected ? ' selected' : '') + '>' + (s || '- Pilih -') + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openForm(id) {
|
||||
var c = id ? Store.candidateById(id) : null;
|
||||
var iv = (c && c.interview) || {};
|
||||
var defStatus = Store.sortedStatuses()[0];
|
||||
var today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
var body =
|
||||
'<div class="grid cols-2">' +
|
||||
'<div class="field"><label for="f-nama">Nama lengkap *</label><input type="text" id="f-nama" value="' + esc(c ? c.nama : '') + '"></div>' +
|
||||
'<div class="field"><label for="f-phone">No. WhatsApp *</label><input type="text" id="f-phone" placeholder="08xxxxxxxxxx" value="' + esc(c ? c.phoneRaw : '') + '"><div class="hint" id="phoneHint"></div></div>' +
|
||||
'</div>' +
|
||||
'<div class="grid cols-2">' +
|
||||
'<div class="field"><label for="f-posisi">Posisi dilamar *</label><input type="text" id="f-posisi" value="' + esc(c ? c.posisi : '') + '"></div>' +
|
||||
'<div class="field"><label for="f-status">Status</label><select id="f-status">' + statusOptions(c ? c.statusId : (defStatus && defStatus.id)) + '</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="grid cols-2">' +
|
||||
'<div class="field"><label for="f-email">Email</label><input type="email" id="f-email" value="' + esc(c ? c.email : '') + '"></div>' +
|
||||
'<div class="field"><label for="f-domisili">Domisili</label><input type="text" id="f-domisili" value="' + esc(c ? c.domisili : '') + '"></div>' +
|
||||
'</div>' +
|
||||
'<div class="grid cols-3">' +
|
||||
'<div class="field"><label for="f-sumber">Sumber</label><select id="f-sumber">' + sumberOptions(c ? c.sumber : '') + '</select></div>' +
|
||||
'<div class="field"><label for="f-gaji">Gaji ekspektasi</label><input type="text" id="f-gaji" placeholder="cth: 5.000.000" value="' + esc(c ? c.gajiEkspektasi : '') + '"></div>' +
|
||||
'<div class="field"><label for="f-tanggal">Tanggal melamar</label><input type="date" id="f-tanggal" value="' + esc(c ? c.tanggalMelamar : today) + '"></div>' +
|
||||
'</div>' +
|
||||
'<h4 class="mt2">Jadwal Interview</h4>' +
|
||||
'<div class="grid cols-3">' +
|
||||
'<div class="field"><label for="i-tanggal">Tanggal</label><input type="date" id="i-tanggal" value="' + esc(iv.tanggal || '') + '"></div>' +
|
||||
'<div class="field"><label for="i-jam">Jam</label><input type="time" id="i-jam" value="' + esc(iv.jam || '') + '"></div>' +
|
||||
'<div class="field"><label for="i-metode">Metode</label><select id="i-metode">' +
|
||||
['', 'Online', 'Offline'].map(function (m) {
|
||||
return '<option value="' + m + '"' + (iv.metode === m ? ' selected' : '') + '>' + (m || '- Pilih -') + '</option>';
|
||||
}).join('') + '</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="grid cols-3">' +
|
||||
'<div class="field"><label for="i-pewawancara">Nama pewawancara</label><input type="text" id="i-pewawancara" value="' + esc(iv.pewawancara || '') + '"></div>' +
|
||||
'<div class="field"><label for="i-telp">No. WA pewawancara</label><input type="text" id="i-telp" placeholder="08xxxxxxxxxx" value="' + esc(iv.telpPewawancara || '') + '"><div class="hint" id="telpHint"></div></div>' +
|
||||
'<div class="field"><label for="i-lokasi">Lokasi / Link meeting</label><input type="text" id="i-lokasi" value="' + esc(iv.lokasi || '') + '"></div>' +
|
||||
'</div>' +
|
||||
'<div class="field"><label for="f-catatan">Catatan HR (internal)</label><textarea id="f-catatan">' + esc(c ? c.catatan : '') + '</textarea></div>';
|
||||
|
||||
var footer =
|
||||
'<button class="btn btn-ghost" type="button" data-close>Batal</button>' +
|
||||
'<button class="btn btn-primary" type="button" id="saveCandidate">' + (id ? 'Simpan Perubahan' : 'Tambah Pelamar') + '</button>';
|
||||
|
||||
UI.openModal({
|
||||
title: id ? 'Edit Pelamar' : 'Tambah Pelamar',
|
||||
size: 'lg',
|
||||
body: body,
|
||||
footer: footer,
|
||||
onMount: function (el) {
|
||||
var phoneInput = el.querySelector('#f-phone');
|
||||
var hint = el.querySelector('#phoneHint');
|
||||
function updHint() {
|
||||
var norm = Phone.normalize(phoneInput.value);
|
||||
if (!phoneInput.value.trim()) { hint.textContent = ''; return; }
|
||||
hint.innerHTML = Phone.isValid(norm)
|
||||
? '<span style="color:#16a34a">✓ ' + Phone.formatDisplay(norm) + '</span>'
|
||||
: '<span style="color:#dc2626">Nomor belum valid</span>';
|
||||
}
|
||||
phoneInput.addEventListener('input', updHint);
|
||||
updHint();
|
||||
|
||||
var telpInput = el.querySelector('#i-telp');
|
||||
var telpHint = el.querySelector('#telpHint');
|
||||
function updTelpHint() {
|
||||
var norm = Phone.normalize(telpInput.value);
|
||||
if (!telpInput.value.trim()) { telpHint.textContent = ''; return; }
|
||||
telpHint.innerHTML = Phone.isValid(norm)
|
||||
? '<span style="color:#16a34a">✓ ' + Phone.formatDisplay(norm) + '</span>'
|
||||
: '<span style="color:#dc2626">Nomor belum valid</span>';
|
||||
}
|
||||
telpInput.addEventListener('input', updTelpHint);
|
||||
updTelpHint();
|
||||
|
||||
el.querySelector('[data-close]').addEventListener('click', UI.closeModal);
|
||||
el.querySelector('#saveCandidate').addEventListener('click', function () { saveCandidate(id); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveCandidate(id) {
|
||||
var el = document;
|
||||
var nama = $('#f-nama').value.trim();
|
||||
var phoneRaw = $('#f-phone').value.trim();
|
||||
var phoneNorm = Phone.normalize(phoneRaw);
|
||||
var posisi = $('#f-posisi').value.trim();
|
||||
|
||||
if (!nama) { UI.toast('Nama wajib diisi.', 'err'); return; }
|
||||
if (!phoneRaw) { UI.toast('No. WhatsApp wajib diisi.', 'err'); return; }
|
||||
if (!Phone.isValid(phoneNorm)) { UI.toast('Nomor WhatsApp tidak valid.', 'err'); return; }
|
||||
if (!posisi) { UI.toast('Posisi wajib diisi.', 'err'); return; }
|
||||
|
||||
var data = {
|
||||
nama: nama, phoneRaw: phoneRaw, phoneNorm: phoneNorm,
|
||||
email: $('#f-email').value.trim(),
|
||||
posisi: posisi,
|
||||
statusId: $('#f-status').value,
|
||||
sumber: $('#f-sumber').value,
|
||||
domisili: $('#f-domisili').value.trim(),
|
||||
gajiEkspektasi: $('#f-gaji').value.trim(),
|
||||
tanggalMelamar: $('#f-tanggal').value,
|
||||
catatan: $('#f-catatan').value.trim(),
|
||||
interview: {
|
||||
tanggal: $('#i-tanggal').value,
|
||||
jam: $('#i-jam').value,
|
||||
metode: $('#i-metode').value,
|
||||
lokasi: $('#i-lokasi').value.trim(),
|
||||
pewawancara: $('#i-pewawancara').value.trim(),
|
||||
telpPewawancara: $('#i-telp').value.trim()
|
||||
}
|
||||
};
|
||||
|
||||
if (id) { Store.updateCandidate(id, data); UI.toast('Data pelamar diperbarui.', 'ok'); }
|
||||
else { Store.addCandidate(data); UI.toast('Pelamar berhasil ditambahkan.', 'ok'); }
|
||||
|
||||
UI.closeModal();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
/* ---------------- Detail ---------------- */
|
||||
function openDetail(id) {
|
||||
var c = Store.candidateById(id);
|
||||
if (!c) return;
|
||||
var st = Store.statusById(c.statusId);
|
||||
var iv = c.interview || {};
|
||||
|
||||
var info =
|
||||
'<div class="grid cols-2">' +
|
||||
'<div><div class="muted small">Nama</div><div><strong>' + esc(c.nama) + '</strong></div></div>' +
|
||||
'<div><div class="muted small">Posisi</div><div>' + esc(c.posisi) + '</div></div>' +
|
||||
'<div><div class="muted small">No. WhatsApp</div><div>' + esc(c.phoneNorm ? Phone.formatDisplay(c.phoneNorm) : c.phoneRaw) + '</div></div>' +
|
||||
'<div><div class="muted small">Status</div><div>' + UI.badge(st) + '</div></div>' +
|
||||
'<div><div class="muted small">Email</div><div>' + esc(c.email || '-') + '</div></div>' +
|
||||
'<div><div class="muted small">Domisili</div><div>' + esc(c.domisili || '-') + '</div></div>' +
|
||||
'<div><div class="muted small">Sumber</div><div>' + esc(c.sumber || '-') + '</div></div>' +
|
||||
'<div><div class="muted small">Gaji ekspektasi</div><div>' + esc(c.gajiEkspektasi || '-') + '</div></div>' +
|
||||
'<div><div class="muted small">Tanggal melamar</div><div>' + esc(UI.formatDate(c.tanggalMelamar)) + '</div></div>' +
|
||||
'<div><div class="muted small">Pewawancara</div><div>' + esc(iv.pewawancara || '-') +
|
||||
(iv.telpPewawancara ? '<div class="muted small">' + esc(Phone.formatDisplay(Phone.normalize(iv.telpPewawancara))) + '</div>' : '') + '</div></div>' +
|
||||
'</div>';
|
||||
|
||||
var jadwal = iv.tanggal
|
||||
? '<div class="mt1"><div class="muted small">Jadwal Interview</div><div>' +
|
||||
esc(WA.tanggalLengkap(iv.tanggal)) + (iv.jam ? ' pukul ' + esc(iv.jam) : '') +
|
||||
(iv.metode ? ' (' + esc(iv.metode) + ')' : '') +
|
||||
(iv.lokasi ? '<br><span class="muted">' + esc(iv.lokasi) + '</span>' : '') +
|
||||
'</div></div>'
|
||||
: '<div class="mt1 muted">Belum ada jadwal interview.</div>';
|
||||
|
||||
var catatan = c.catatan ? '<div class="mt1"><div class="muted small">Catatan HR</div><div>' + esc(c.catatan) + '</div></div>' : '';
|
||||
|
||||
var berkas = '<h4 class="mt2">Berkas</h4>' + SLOTS.map(function (slot) {
|
||||
var b = c.berkas && c.berkas[slot.key];
|
||||
var value = '-';
|
||||
if (b && b.mode === 'link') value = '<a href="' + esc(b.url) + '" target="_blank" rel="noopener">' + esc(b.url) + '</a>';
|
||||
else if (b && b.mode === 'file') value = esc(b.name) + ' <span class="muted small">(' + UI.formatBytes(b.size) + ')</span>';
|
||||
return '<div class="berkas-row">' +
|
||||
'<div class="berkas-label">' + slot.label + '</div>' +
|
||||
'<div class="berkas-value">' + value + '</div>' +
|
||||
'<div class="berkas-actions">' +
|
||||
(b ? '<button class="btn btn-ghost btn-sm" data-berkas-open="' + slot.key + '" data-id="' + c.id + '">Lihat</button>' : '') +
|
||||
'<label class="btn btn-ghost btn-sm" style="margin:0">Upload<input type="file" hidden data-berkas-file="' + slot.key + '" data-id="' + c.id + '" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"></label>' +
|
||||
'<button class="btn btn-ghost btn-sm" data-berkas-link="' + slot.key + '" data-id="' + c.id + '">Link</button>' +
|
||||
(b ? '<button class="btn btn-danger btn-sm" data-berkas-del="' + slot.key + '" data-id="' + c.id + '">Hapus</button>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
var history = (c.history || []).slice().reverse().map(function (h) {
|
||||
var dari = h.statusLama ? statusName(h.statusLama) : '(baru)';
|
||||
return '<div class="small muted">' + esc(UI.formatDateTime(h.at)) + ' — ' + esc(dari) + ' → <strong>' + esc(statusName(h.statusBaru)) + '</strong></div>';
|
||||
}).join('') || '<div class="small muted">Belum ada riwayat.</div>';
|
||||
|
||||
var footer =
|
||||
'<button class="btn btn-ghost" type="button" data-close>Tutup</button>' +
|
||||
'<button class="btn btn-warn" type="button" data-act="reminder" data-id="' + c.id + '">Reminder Pelamar</button>' +
|
||||
'<button class="btn btn-warn" type="button" data-act="pewawancara" data-id="' + c.id + '">Reminder Pewawancara</button>' +
|
||||
'<button class="btn btn-primary" type="button" data-act="edit" data-id="' + c.id + '">Edit</button>';
|
||||
|
||||
UI.openModal({
|
||||
title: 'Detail Pelamar',
|
||||
size: 'lg',
|
||||
body: info + jadwal + catatan + berkas +
|
||||
'<h4 class="mt2">Riwayat Status</h4><div>' + history + '</div>',
|
||||
footer: footer,
|
||||
onMount: function (el) {
|
||||
el.querySelector('[data-close]').addEventListener('click', UI.closeModal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- WhatsApp ---------------- */
|
||||
function openWaDialog(id, mode) {
|
||||
var c = Store.candidateById(id);
|
||||
if (!c) return;
|
||||
|
||||
var targetPhone = c.phoneNorm;
|
||||
if (mode === 'pewawancara') {
|
||||
targetPhone = Phone.normalize((c.interview && c.interview.telpPewawancara) || '');
|
||||
if (!Phone.isValid(targetPhone)) {
|
||||
UI.toast('Nomor WA pewawancara belum diisi / tidak valid. Isi lewat tombol Edit.', 'err');
|
||||
return;
|
||||
}
|
||||
} else if (!Phone.isValid(targetPhone)) {
|
||||
UI.toast('Nomor WhatsApp tidak valid.', 'err');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'reminder' && !(c.interview && c.interview.tanggal)) {
|
||||
if (!UI.confirmDialog('Jadwal interview belum diisi. Bagian hari/tanggal/jam pada pesan akan kosong. Tetap lanjutkan?')) return;
|
||||
}
|
||||
|
||||
var templates = Store.get().templates.filter(function (t) { return t.isActive !== false; });
|
||||
if (!templates.length) { UI.toast('Belum ada template aktif. Buka menu Template Pesan.', 'err'); return; }
|
||||
|
||||
var preselect = null;
|
||||
var jenisCari = mode === 'reminder' ? 'reminder' : (mode === 'pewawancara' ? 'pewawancara' : null);
|
||||
if (jenisCari) preselect = templates.filter(function (t) { return t.jenis === jenisCari; })[0];
|
||||
if (!preselect) preselect = templates.filter(function (t) { return t.statusId === c.statusId; })[0];
|
||||
if (!preselect) preselect = templates[0];
|
||||
|
||||
var ctx = WA.context(c, Store.get().settings);
|
||||
ctx.status = statusName(c.statusId);
|
||||
|
||||
var options = templates.map(function (t) {
|
||||
return '<option value="' + t.id + '"' + (t.id === preselect.id ? ' selected' : '') + '>' + esc(t.nama) + '</option>';
|
||||
}).join('');
|
||||
|
||||
var body =
|
||||
'<div class="field"><label for="wa-tpl">Template pesan</label><select id="wa-tpl">' + options + '</select></div>' +
|
||||
'<div class="field"><label>Preview pesan</label><div class="preview-box" id="wa-preview"></div></div>' +
|
||||
'<div class="hint">Pesan akan terbuka di WhatsApp dan Anda tinggal menekan tombol kirim.</div>';
|
||||
|
||||
var footer =
|
||||
'<button class="btn btn-ghost" type="button" data-close>Batal</button>' +
|
||||
'<button class="btn btn-wa" type="button" id="waOpen">Buka WhatsApp</button>';
|
||||
|
||||
UI.openModal({
|
||||
title: mode === 'reminder' ? 'Reminder Interview (Pelamar)' : (mode === 'pewawancara' ? 'Reminder Interview (Pewawancara)' : 'Kirim WhatsApp'),
|
||||
body: body,
|
||||
footer: footer,
|
||||
onMount: function (el) {
|
||||
var sel = el.querySelector('#wa-tpl');
|
||||
var prev = el.querySelector('#wa-preview');
|
||||
function update() {
|
||||
var t = Store.templateById(sel.value);
|
||||
prev.textContent = t ? WA.render(t.isi, ctx) : '';
|
||||
}
|
||||
sel.addEventListener('change', update);
|
||||
update();
|
||||
el.querySelector('[data-close]').addEventListener('click', UI.closeModal);
|
||||
el.querySelector('#waOpen').addEventListener('click', function () {
|
||||
var t = Store.templateById(sel.value);
|
||||
if (!t) return;
|
||||
WA.open(targetPhone, WA.render(t.isi, ctx));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Berkas ---------------- */
|
||||
function setBerkas(id, slot, value) {
|
||||
var c = Store.candidateById(id);
|
||||
if (!c) return;
|
||||
c.berkas = c.berkas || { cv: null, test: null, portofolio: null };
|
||||
var old = c.berkas[slot];
|
||||
if (old && old.mode === 'file' && old.fileId && (!value || value.fileId !== old.fileId)) {
|
||||
Store.deleteFile(old.fileId).catch(function () {});
|
||||
}
|
||||
c.berkas[slot] = value;
|
||||
Store.save();
|
||||
}
|
||||
|
||||
function handleBerkasFile(input) {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var id = input.getAttribute('data-id');
|
||||
var slot = input.getAttribute('data-berkas-file');
|
||||
if (file.size > MAX_FILE) { UI.toast('Ukuran berkas maksimal 5 MB.', 'err'); input.value = ''; return; }
|
||||
UI.toast('Mengunggah berkas...');
|
||||
Store.saveFile(file).then(function (meta) {
|
||||
setBerkas(id, slot, { mode: 'file', fileId: meta.id, name: meta.name, size: meta.size, type: meta.type });
|
||||
UI.toast('Berkas tersimpan.', 'ok');
|
||||
openDetail(id);
|
||||
}).catch(function (err) {
|
||||
UI.toast('Gagal menyimpan berkas: ' + err.message, 'err');
|
||||
});
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
/* ---------------- Export / Import Excel ---------------- */
|
||||
function doExportExcel() {
|
||||
var list = Store.get().candidates;
|
||||
if (!list.length) { UI.toast('Belum ada data pelamar untuk diexport.', 'err'); return; }
|
||||
try {
|
||||
Excel.exportCandidates(list, Store.get().statuses);
|
||||
UI.toast('File Excel diunduh.', 'ok');
|
||||
} catch (err) {
|
||||
UI.toast('Gagal export Excel: ' + err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function doImportExcel(file) {
|
||||
UI.toast('Membaca file Excel...');
|
||||
Excel.readRows(file).then(function (json) {
|
||||
var mapped = Excel.mapRows(json, Store.get().statuses);
|
||||
var valid = [], invalid = 0;
|
||||
mapped.forEach(function (row) {
|
||||
var norm = Phone.normalize(row.phoneRaw || '');
|
||||
if (!row.nama || !Phone.isValid(norm)) { invalid++; return; }
|
||||
row.phoneNorm = norm;
|
||||
valid.push(row);
|
||||
});
|
||||
if (!valid.length) {
|
||||
UI.toast('Tidak ada baris valid. Kolom "Nama" dan "No WhatsApp" wajib terisi.', 'err');
|
||||
return;
|
||||
}
|
||||
var pesan = 'Ditemukan ' + valid.length + ' baris valid' +
|
||||
(invalid ? ' dan ' + invalid + ' baris dilewati (nama/No WhatsApp kosong atau tidak valid)' : '') +
|
||||
'.\n\nTambahkan baris valid ke daftar pelamar?';
|
||||
if (!UI.confirmDialog(pesan)) return;
|
||||
valid.forEach(function (row) { Store.addCandidate(row); });
|
||||
UI.toast(valid.length + ' pelamar ditambahkan.', 'ok');
|
||||
renderAll();
|
||||
}).catch(function (err) {
|
||||
UI.toast('Gagal import Excel: ' + err.message, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Events ---------------- */
|
||||
function bindEvents() {
|
||||
$('#btnAdd').addEventListener('click', function () { openForm(null); });
|
||||
|
||||
$('#fSearch').addEventListener('input', function (e) { filters.q = e.target.value; renderList(); });
|
||||
$('#fStatus').addEventListener('change', function (e) { filters.status = e.target.value; renderList(); });
|
||||
$('#fPosisi').addEventListener('change', function (e) { filters.posisi = e.target.value; renderList(); });
|
||||
$('#fSumber').addEventListener('change', function (e) { filters.sumber = e.target.value; renderList(); });
|
||||
$('#fSort').addEventListener('change', function (e) { filters.sort = e.target.value; renderList(); });
|
||||
|
||||
$('#btnExportExcel').addEventListener('click', doExportExcel);
|
||||
$('#btnImportExcel').addEventListener('click', function () { $('#importExcelFile').click(); });
|
||||
$('#importExcelFile').addEventListener('change', function (e) {
|
||||
if (e.target.files && e.target.files[0]) doImportExcel(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// Delegasi klik aksi
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-act]');
|
||||
if (btn) {
|
||||
var id = btn.getAttribute('data-id');
|
||||
var act = btn.getAttribute('data-act');
|
||||
if (act === 'wa') openWaDialog(id, 'status');
|
||||
else if (act === 'reminder') openWaDialog(id, 'reminder');
|
||||
else if (act === 'pewawancara') openWaDialog(id, 'pewawancara');
|
||||
else if (act === 'detail') openDetail(id);
|
||||
else if (act === 'edit') openForm(id);
|
||||
else if (act === 'del') {
|
||||
var c = Store.candidateById(id);
|
||||
if (c && UI.confirmDialog('Hapus pelamar "' + c.nama + '"? Tindakan ini tidak bisa dibatalkan.')) {
|
||||
Store.deleteCandidate(id);
|
||||
UI.toast('Pelamar dihapus.', 'ok');
|
||||
UI.closeModal();
|
||||
renderAll();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var openBtn = e.target.closest('[data-berkas-open]');
|
||||
if (openBtn) {
|
||||
var cid = openBtn.getAttribute('data-id');
|
||||
var slot = openBtn.getAttribute('data-berkas-open');
|
||||
var cand = Store.candidateById(cid);
|
||||
var b = cand && cand.berkas && cand.berkas[slot];
|
||||
if (!b) return;
|
||||
if (b.mode === 'link') { window.open(b.url, '_blank', 'noopener'); return; }
|
||||
Store.getFileUrl(b.fileId).then(function (url) { window.open(url, '_blank', 'noopener'); })
|
||||
.catch(function () { UI.toast('Berkas tidak ditemukan.', 'err'); });
|
||||
return;
|
||||
}
|
||||
|
||||
var linkBtn = e.target.closest('[data-berkas-link]');
|
||||
if (linkBtn) {
|
||||
var cid2 = linkBtn.getAttribute('data-id');
|
||||
var slot2 = linkBtn.getAttribute('data-berkas-link');
|
||||
var url = window.prompt('Masukkan URL berkas (Google Drive, dll):');
|
||||
if (url && url.trim()) {
|
||||
setBerkas(cid2, slot2, { mode: 'link', url: url.trim() });
|
||||
UI.toast('Link tersimpan.', 'ok');
|
||||
openDetail(cid2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var delBtn = e.target.closest('[data-berkas-del]');
|
||||
if (delBtn) {
|
||||
var cid3 = delBtn.getAttribute('data-id');
|
||||
var slot3 = delBtn.getAttribute('data-berkas-del');
|
||||
if (UI.confirmDialog('Hapus berkas ini?')) {
|
||||
setBerkas(cid3, slot3, null);
|
||||
UI.toast('Berkas dihapus.', 'ok');
|
||||
openDetail(cid3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delegasi input file berkas
|
||||
document.addEventListener('change', function (e) {
|
||||
var input = e.target.closest('[data-berkas-file]');
|
||||
if (input) handleBerkasFile(input);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
@@ -0,0 +1,132 @@
|
||||
/* berkas.js — Halaman daftar & unggah berkas pelamar */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (s, r) { return (r || document).querySelector(s); };
|
||||
var SLOTS = [
|
||||
{ key: 'cv', label: 'CV' },
|
||||
{ key: 'test', label: 'Hasil Test' },
|
||||
{ key: 'portofolio', label: 'Portofolio' }
|
||||
];
|
||||
var MAX_FILE = 5 * 1024 * 1024; // 5 MB
|
||||
var q = '';
|
||||
|
||||
function esc(v) { return UI.escape(v); }
|
||||
|
||||
function init() {
|
||||
Store.init();
|
||||
render();
|
||||
$('#bSearch').addEventListener('input', function (e) { q = e.target.value.toLowerCase(); render(); });
|
||||
}
|
||||
|
||||
function slotCell(c, slot) {
|
||||
var b = c.berkas && c.berkas[slot.key];
|
||||
var value = '<span class="muted small">Belum ada</span>';
|
||||
if (b) {
|
||||
var label = b.mode === 'file' ? b.name : (b.url.length > 34 ? b.url.slice(0, 34) + '…' : b.url);
|
||||
value = '<div>' + (b.mode === 'file' ? '📎 ' : '🔗 ') + esc(label) +
|
||||
(b.mode === 'file' ? ' <span class="muted small">(' + UI.formatBytes(b.size) + ')</span>' : '') + '</div>';
|
||||
}
|
||||
return '<td>' + value +
|
||||
'<div class="actions mt1">' +
|
||||
(b ? '<button class="btn btn-ghost btn-sm" data-open="' + slot.key + '" data-id="' + c.id + '">Lihat</button>' : '') +
|
||||
'<label class="btn btn-ghost btn-sm" style="margin:0">Upload<input type="file" hidden data-berkas-file="' + slot.key + '" data-id="' + c.id + '" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"></label>' +
|
||||
'<button class="btn btn-ghost btn-sm" data-berkas-link="' + slot.key + '" data-id="' + c.id + '">Link</button>' +
|
||||
(b ? '<button class="btn btn-danger btn-sm" data-berkas-del="' + slot.key + '" data-id="' + c.id + '">Hapus</button>' : '') +
|
||||
'</div></td>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var list = Store.get().candidates.filter(function (c) {
|
||||
if (!q) return true;
|
||||
return (c.nama || '').toLowerCase().indexOf(q) >= 0 || (c.posisi || '').toLowerCase().indexOf(q) >= 0;
|
||||
});
|
||||
|
||||
if (!list.length) {
|
||||
$('#bBody').innerHTML = '';
|
||||
$('#bEmpty').classList.remove('hidden');
|
||||
$('.table-wrap').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
$('#bEmpty').classList.add('hidden');
|
||||
$('.table-wrap').classList.remove('hidden');
|
||||
|
||||
$('#bBody').innerHTML = list.map(function (c) {
|
||||
return '<tr>' +
|
||||
'<td><strong>' + esc(c.nama) + '</strong><div class="muted small">' + esc(c.posisi) + '</div></td>' +
|
||||
SLOTS.map(function (s) { return slotCell(c, s); }).join('') +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setBerkas(id, slot, value) {
|
||||
var c = Store.candidateById(id);
|
||||
if (!c) return;
|
||||
c.berkas = c.berkas || { cv: null, test: null, portofolio: null };
|
||||
var old = c.berkas[slot];
|
||||
if (old && old.mode === 'file' && old.fileId && (!value || value.fileId !== old.fileId)) {
|
||||
Store.deleteFile(old.fileId).catch(function () {});
|
||||
}
|
||||
c.berkas[slot] = value;
|
||||
Store.save();
|
||||
}
|
||||
|
||||
function handleFile(input) {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var id = input.getAttribute('data-id');
|
||||
var slot = input.getAttribute('data-berkas-file');
|
||||
if (file.size > MAX_FILE) { UI.toast('Ukuran berkas maksimal 5 MB.', 'err'); input.value = ''; return; }
|
||||
UI.toast('Menyimpan berkas...');
|
||||
Store.saveFile(file).then(function (meta) {
|
||||
setBerkas(id, slot, { mode: 'file', fileId: meta.id, name: meta.name, size: meta.size, type: meta.type });
|
||||
UI.toast('Berkas tersimpan.', 'ok');
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
UI.toast('Gagal menyimpan berkas: ' + err.message, 'err');
|
||||
});
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var openBtn = e.target.closest('[data-open]');
|
||||
if (openBtn) {
|
||||
var c = Store.candidateById(openBtn.getAttribute('data-id'));
|
||||
var b = c && c.berkas && c.berkas[openBtn.getAttribute('data-open')];
|
||||
if (!b) return;
|
||||
if (b.mode === 'link') { window.open(b.url, '_blank', 'noopener'); return; }
|
||||
Store.getFileUrl(b.fileId).then(function (url) { window.open(url, '_blank', 'noopener'); })
|
||||
.catch(function () { UI.toast('Berkas tidak ditemukan.', 'err'); });
|
||||
return;
|
||||
}
|
||||
|
||||
var linkBtn = e.target.closest('[data-berkas-link]');
|
||||
if (linkBtn) {
|
||||
var id = linkBtn.getAttribute('data-id');
|
||||
var slot = linkBtn.getAttribute('data-berkas-link');
|
||||
var url = window.prompt('Masukkan URL berkas (Google Drive, dll):');
|
||||
if (url && url.trim()) {
|
||||
setBerkas(id, slot, { mode: 'link', url: url.trim() });
|
||||
UI.toast('Link tersimpan.', 'ok');
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var delBtn = e.target.closest('[data-berkas-del]');
|
||||
if (delBtn) {
|
||||
if (UI.confirmDialog('Hapus berkas ini?')) {
|
||||
setBerkas(delBtn.getAttribute('data-id'), delBtn.getAttribute('data-berkas-del'), null);
|
||||
UI.toast('Berkas dihapus.', 'ok');
|
||||
render();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (e) {
|
||||
var input = e.target.closest('[data-berkas-file]');
|
||||
if (input) handleFile(input);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
@@ -0,0 +1,212 @@
|
||||
/* excel.js — Export & Import data pelamar ke/dari Excel (SheetJS) */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var HEADERS = [
|
||||
'No', 'Nama', 'No WhatsApp', 'Email', 'Posisi', 'Status', 'Sumber', 'Domisili',
|
||||
'Gaji Ekspektasi', 'Tanggal Melamar', 'Catatan',
|
||||
'Tanggal Interview', 'Jam Interview', 'Metode', 'Lokasi/Link',
|
||||
'Pewawancara', 'No WA Pewawancara'
|
||||
];
|
||||
|
||||
var COL_WIDTH = [5, 24, 16, 24, 20, 18, 12, 16, 16, 16, 30, 16, 12, 10, 24, 18, 18];
|
||||
|
||||
var ALIASES = {
|
||||
nama: ['nama', 'nama lengkap', 'name', 'full name'],
|
||||
phoneRaw: ['no whatsapp', 'no wa', 'nomor whatsapp', 'nomor wa', 'no hp', 'no. hp', 'whatsapp', 'phone', 'telepon', 'no telp', 'no. telp'],
|
||||
email: ['email', 'e-mail'],
|
||||
posisi: ['posisi', 'posisi dilamar', 'posisi yang dilamar', 'jabatan', 'position'],
|
||||
status: ['status', 'status rekrutmen'],
|
||||
sumber: ['sumber', 'sumber pelamar', 'source'],
|
||||
domisili: ['domisili', 'alamat', 'kota'],
|
||||
gajiEkspektasi: ['gaji ekspektasi', 'gaji', 'ekspektasi gaji', 'expected salary'],
|
||||
tanggalMelamar: ['tanggal melamar', 'tgl melamar', 'tanggal', 'date'],
|
||||
catatan: ['catatan', 'catatan hr', 'keterangan', 'note', 'notes'],
|
||||
ivTanggal: ['tanggal interview', 'tgl interview', 'jadwal interview'],
|
||||
ivJam: ['jam interview', 'jam', 'waktu'],
|
||||
ivMetode: ['metode', 'metode interview'],
|
||||
ivLokasi: ['lokasi/link', 'lokasi', 'link', 'lokasi interview', 'lokasi / link', 'tautan'],
|
||||
pewawancara: ['pewawancara', 'nama pewawancara', 'interviewer'],
|
||||
telpPewawancara: ['no wa pewawancara', 'no. wa pewawancara', 'no hp pewawancara', 'telp pewawancara', 'whatsapp pewawancara']
|
||||
};
|
||||
|
||||
function hasLib() { return typeof global.XLSX !== 'undefined' && global.XLSX; }
|
||||
|
||||
function statusName(id, statuses) {
|
||||
var s = (statuses || []).filter(function (x) { return x.id === id; })[0];
|
||||
return s ? s.nama : '';
|
||||
}
|
||||
|
||||
function resolveStatus(nama, statuses) {
|
||||
var target = String(nama || '').trim().toLowerCase();
|
||||
var list = statuses || [];
|
||||
if (target) {
|
||||
var found = list.filter(function (s) { return String(s.nama).trim().toLowerCase() === target; })[0];
|
||||
if (found) return found.id;
|
||||
var partial = list.filter(function (s) {
|
||||
var n = String(s.nama).trim().toLowerCase();
|
||||
return n.indexOf(target) >= 0 || target.indexOf(n) >= 0;
|
||||
})[0];
|
||||
if (partial) return partial.id;
|
||||
}
|
||||
return list.length ? list.slice().sort(function (a, b) { return (a.urutan || 0) - (b.urutan || 0); })[0].id : 'baru';
|
||||
}
|
||||
|
||||
function normalizeKey(k) {
|
||||
return String(k == null ? '' : k).trim().toLowerCase().replace(/\s+/g, ' ').replace(/:$/, '');
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function toDateStr(v) {
|
||||
if (v == null || v === '') return '';
|
||||
if (v instanceof Date) return v.getFullYear() + '-' + pad(v.getMonth() + 1) + '-' + pad(v.getDate());
|
||||
if (typeof v === 'number' && isFinite(v)) {
|
||||
var epoch = Date.UTC(1899, 11, 30);
|
||||
var d = new Date(epoch + v * 86400000);
|
||||
return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
|
||||
}
|
||||
var s = String(v).trim();
|
||||
var iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
|
||||
if (iso) return iso[1] + '-' + pad(iso[2]) + '-' + pad(iso[3]);
|
||||
var dmy = s.match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2,4})$/);
|
||||
if (dmy) {
|
||||
var yr = dmy[3].length === 2 ? '20' + dmy[3] : dmy[3];
|
||||
return yr + '-' + pad(dmy[2]) + '-' + pad(dmy[1]);
|
||||
}
|
||||
var d2 = new Date(s);
|
||||
if (!isNaN(d2.getTime())) return d2.getFullYear() + '-' + pad(d2.getMonth() + 1) + '-' + pad(d2.getDate());
|
||||
return '';
|
||||
}
|
||||
|
||||
function toTimeStr(v) {
|
||||
if (v == null || v === '') return '';
|
||||
if (v instanceof Date) return pad(v.getHours()) + ':' + pad(v.getMinutes());
|
||||
if (typeof v === 'number' && isFinite(v)) {
|
||||
var total = Math.round(v * 24 * 60);
|
||||
return pad(Math.floor(total / 60) % 24) + ':' + pad(total % 60);
|
||||
}
|
||||
var s = String(v).trim();
|
||||
var m = s.match(/^(\d{1,2})[:.](\d{2})/);
|
||||
if (m) return pad(m[1]) + ':' + m[2];
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalizeMetode(v) {
|
||||
var s = String(v == null ? '' : v).trim().toLowerCase();
|
||||
if (s.indexOf('online') >= 0 || s.indexOf('daring') >= 0) return 'Online';
|
||||
if (s.indexOf('offline') >= 0 || s.indexOf('luring') >= 0 || s.indexOf('kantor') >= 0) return 'Offline';
|
||||
return '';
|
||||
}
|
||||
|
||||
function exportCandidates(candidates, statuses) {
|
||||
if (!hasLib()) throw new Error('Pustaka Excel tidak termuat.');
|
||||
var rows = (candidates || []).map(function (c, i) {
|
||||
var iv = c.interview || {};
|
||||
var o = {};
|
||||
o['No'] = i + 1;
|
||||
o['Nama'] = c.nama || '';
|
||||
o['No WhatsApp'] = c.phoneNorm || global.Phone.normalize(c.phoneRaw || '');
|
||||
o['Email'] = c.email || '';
|
||||
o['Posisi'] = c.posisi || '';
|
||||
o['Status'] = statusName(c.statusId, statuses);
|
||||
o['Sumber'] = c.sumber || '';
|
||||
o['Domisili'] = c.domisili || '';
|
||||
o['Gaji Ekspektasi'] = c.gajiEkspektasi || '';
|
||||
o['Tanggal Melamar'] = c.tanggalMelamar || '';
|
||||
o['Catatan'] = c.catatan || '';
|
||||
o['Tanggal Interview'] = iv.tanggal || '';
|
||||
o['Jam Interview'] = iv.jam || '';
|
||||
o['Metode'] = iv.metode || '';
|
||||
o['Lokasi/Link'] = iv.lokasi || '';
|
||||
o['Pewawancara'] = iv.pewawancara || '';
|
||||
o['No WA Pewawancara'] = iv.telpPewawancara || '';
|
||||
return o;
|
||||
});
|
||||
|
||||
var ws = global.XLSX.utils.json_to_sheet(rows, { header: HEADERS });
|
||||
ws['!cols'] = COL_WIDTH.map(function (w) { return { wch: w }; });
|
||||
var wb = global.XLSX.utils.book_new();
|
||||
global.XLSX.utils.book_append_sheet(wb, ws, 'Pelamar');
|
||||
var stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
global.XLSX.writeFile(wb, 'data-pelamar-' + stamp + '.xlsx');
|
||||
}
|
||||
|
||||
function exportTemplate() {
|
||||
if (!hasLib()) throw new Error('Pustaka Excel tidak termuat.');
|
||||
var ws = global.XLSX.utils.aoa_to_sheet([HEADERS]);
|
||||
ws['!cols'] = COL_WIDTH.map(function (w) { return { wch: w }; });
|
||||
var wb = global.XLSX.utils.book_new();
|
||||
global.XLSX.utils.book_append_sheet(wb, ws, 'Pelamar');
|
||||
global.XLSX.writeFile(wb, 'template-import-pelamar.xlsx');
|
||||
}
|
||||
|
||||
function readRows(file) {
|
||||
if (!hasLib()) return Promise.reject(new Error('Pustaka Excel tidak termuat.'));
|
||||
var isCsv = /\.csv$/i.test(file.name) || file.type === 'text/csv';
|
||||
return new Promise(function (resolve, reject) {
|
||||
var fr = new FileReader();
|
||||
fr.onerror = function () { reject(fr.error || new Error('Gagal membaca file')); };
|
||||
fr.onload = function (e) {
|
||||
try {
|
||||
var wb = isCsv
|
||||
? global.XLSX.read(e.target.result, { type: 'string' })
|
||||
: global.XLSX.read(new Uint8Array(e.target.result), { type: 'array', cellDates: true });
|
||||
var sheetName = wb.SheetNames[0];
|
||||
var ws = wb.Sheets[sheetName];
|
||||
var json = global.XLSX.utils.sheet_to_json(ws, { defval: '', raw: true });
|
||||
resolve(json);
|
||||
} catch (err) {
|
||||
reject(new Error('Format file tidak dikenali. Gunakan .xlsx, .xls, atau .csv.'));
|
||||
}
|
||||
};
|
||||
if (isCsv) fr.readAsText(file); else fr.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
function mapRows(json, statuses) {
|
||||
return (json || []).map(function (row) {
|
||||
var lookup = {};
|
||||
Object.keys(row).forEach(function (k) { lookup[normalizeKey(k)] = row[k]; });
|
||||
function pick(field) {
|
||||
var al = ALIASES[field] || [];
|
||||
for (var i = 0; i < al.length; i++) {
|
||||
var v = lookup[al[i]];
|
||||
if (v != null && String(v).trim() !== '') return v;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return {
|
||||
nama: String(pick('nama')).trim(),
|
||||
phoneRaw: String(pick('phoneRaw')).trim(),
|
||||
email: String(pick('email')).trim(),
|
||||
posisi: String(pick('posisi')).trim(),
|
||||
statusId: resolveStatus(pick('status'), statuses),
|
||||
sumber: String(pick('sumber')).trim(),
|
||||
domisili: String(pick('domisili')).trim(),
|
||||
gajiEkspektasi: String(pick('gajiEkspektasi')).trim(),
|
||||
tanggalMelamar: toDateStr(pick('tanggalMelamar')),
|
||||
catatan: String(pick('catatan')).trim(),
|
||||
interview: {
|
||||
tanggal: toDateStr(pick('ivTanggal')),
|
||||
jam: toTimeStr(pick('ivJam')),
|
||||
metode: normalizeMetode(pick('ivMetode')),
|
||||
lokasi: String(pick('ivLokasi')).trim(),
|
||||
pewawancara: String(pick('pewawancara')).trim(),
|
||||
telpPewawancara: String(pick('telpPewawancara')).trim()
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
global.Excel = {
|
||||
HEADERS: HEADERS,
|
||||
available: hasLib,
|
||||
exportCandidates: exportCandidates,
|
||||
exportTemplate: exportTemplate,
|
||||
readRows: readRows,
|
||||
mapRows: mapRows,
|
||||
toDateStr: toDateStr,
|
||||
toTimeStr: toTimeStr
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,28 @@
|
||||
/* phone.js — Normalisasi & validasi nomor WhatsApp Indonesia */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function normalize(raw) {
|
||||
if (!raw) return '';
|
||||
var s = String(raw).replace(/[^\d+]/g, '');
|
||||
if (s.charAt(0) === '+') s = s.slice(1);
|
||||
if (s.indexOf('620') === 0) s = '62' + s.slice(3);
|
||||
if (s.charAt(0) === '0') s = '62' + s.slice(1);
|
||||
else if (s.charAt(0) === '8') s = '62' + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
function isValid(norm) {
|
||||
return /^62\d{8,13}$/.test(norm || '');
|
||||
}
|
||||
|
||||
function formatDisplay(norm) {
|
||||
if (!norm) return '';
|
||||
if (norm.indexOf('62') !== 0) return '+' + norm;
|
||||
var rest = norm.slice(2);
|
||||
var groups = rest.match(/.{1,4}/g) || [rest];
|
||||
return '+62 ' + groups.join('-');
|
||||
}
|
||||
|
||||
global.Phone = { normalize: normalize, isValid: isValid, formatDisplay: formatDisplay };
|
||||
})(window);
|
||||
@@ -0,0 +1,203 @@
|
||||
/* settings.js — Halaman pengaturan: perusahaan, HR, status, data */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (s, r) { return (r || document).querySelector(s); };
|
||||
function esc(v) { return UI.escape(v); }
|
||||
|
||||
function init() {
|
||||
Store.init();
|
||||
loadSettings();
|
||||
renderStatuses();
|
||||
renderInfo();
|
||||
bind();
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
var s = Store.get().settings;
|
||||
$('#s-perusahaan').value = s.perusahaan || '';
|
||||
$('#s-hr').value = s.hr || '';
|
||||
$('#s-jabatan').value = s.jabatanHr || '';
|
||||
}
|
||||
|
||||
function renderStatuses() {
|
||||
var list = Store.sortedStatuses();
|
||||
$('#statusBody').innerHTML = list.map(function (s) {
|
||||
return '<tr>' +
|
||||
'<td>' + s.urutan + '</td>' +
|
||||
'<td><span class="badge" style="background:' + esc(s.warna) + '">' + esc(s.nama) + '</span></td>' +
|
||||
'<td><code>' + esc(s.warna) + '</code></td>' +
|
||||
'<td>' + (s.isFinal ? 'Ya' : '-') + '</td>' +
|
||||
'<td><div class="actions">' +
|
||||
'<button class="btn btn-ghost btn-sm" data-st-edit="' + s.id + '">Edit</button>' +
|
||||
'<button class="btn btn-danger btn-sm" data-st-del="' + s.id + '">Hapus</button>' +
|
||||
'</div></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderInfo() {
|
||||
Store.fileCount().then(function (n) {
|
||||
$('#dataInfo').textContent = 'Jumlah pelamar: ' + Store.get().candidates.length +
|
||||
' • Template: ' + Store.get().templates.length +
|
||||
' • Berkas tersimpan: ' + n;
|
||||
}).catch(function () {
|
||||
$('#dataInfo').textContent = 'Jumlah pelamar: ' + Store.get().candidates.length +
|
||||
' • Template: ' + Store.get().templates.length;
|
||||
});
|
||||
}
|
||||
|
||||
function openStatusForm(id) {
|
||||
var s = id ? Store.statusById(id) : null;
|
||||
var body =
|
||||
'<div class="field"><label for="st-nama">Nama status *</label><input type="text" id="st-nama" value="' + esc(s ? s.nama : '') + '"></div>' +
|
||||
'<div class="grid cols-3">' +
|
||||
'<div class="field"><label for="st-warna">Warna</label><input type="color" id="st-warna" value="' + esc(s ? s.warna : '#64748b') + '"></div>' +
|
||||
'<div class="field"><label for="st-urutan">Urutan</label><input type="number" id="st-urutan" value="' + (s ? s.urutan : '') + '"></div>' +
|
||||
'<div class="field"><label for="st-final">Status final</label><select id="st-final">' +
|
||||
'<option value="0"' + (s && !s.isFinal ? ' selected' : '') + '>Tidak</option>' +
|
||||
'<option value="1"' + (s && s.isFinal ? ' selected' : '') + '>Ya</option>' +
|
||||
'</select></div>' +
|
||||
'</div>';
|
||||
var footer = '<button class="btn btn-ghost" type="button" data-close>Batal</button>' +
|
||||
'<button class="btn btn-primary" type="button" id="saveStatus">Simpan</button>';
|
||||
|
||||
UI.openModal({
|
||||
title: id ? 'Edit Status' : 'Tambah Status', body: body, footer: footer,
|
||||
onMount: function (el) {
|
||||
el.querySelector('[data-close]').addEventListener('click', UI.closeModal);
|
||||
el.querySelector('#saveStatus').addEventListener('click', function () {
|
||||
var nama = el.querySelector('#st-nama').value.trim();
|
||||
if (!nama) { UI.toast('Nama status wajib diisi.', 'err'); return; }
|
||||
var urutanVal = el.querySelector('#st-urutan').value;
|
||||
Store.saveStatus({
|
||||
id: id || undefined,
|
||||
nama: nama,
|
||||
warna: el.querySelector('#st-warna').value,
|
||||
urutan: urutanVal ? parseInt(urutanVal, 10) : undefined,
|
||||
isFinal: el.querySelector('#st-final').value === '1'
|
||||
});
|
||||
UI.toast('Status disimpan.', 'ok');
|
||||
UI.closeModal();
|
||||
renderStatuses();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function download(filename, text) {
|
||||
var blob = new Blob([text], { type: 'application/json' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url; a.download = filename;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 500);
|
||||
}
|
||||
|
||||
function doExport() {
|
||||
Store.exportData().then(function (data) {
|
||||
var text = JSON.stringify(data);
|
||||
var stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
download('e-recruitment-backup-' + stamp + '.json', text);
|
||||
UI.toast('Backup berhasil diunduh.', 'ok');
|
||||
}).catch(function (err) { UI.toast('Gagal export: ' + err.message, 'err'); });
|
||||
}
|
||||
|
||||
function doImport(file) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var data;
|
||||
try { data = JSON.parse(reader.result); }
|
||||
catch (e) { UI.toast('File JSON tidak valid.', 'err'); return; }
|
||||
if (!UI.confirmDialog('Import akan MENGGANTI seluruh data saat ini. Lanjutkan?')) return;
|
||||
Store.importData(data).then(function () {
|
||||
UI.toast('Data berhasil diimport.', 'ok');
|
||||
loadSettings(); renderStatuses(); renderInfo();
|
||||
}).catch(function (err) { UI.toast('Gagal import: ' + err.message, 'err'); });
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function doExportExcel() {
|
||||
var list = Store.get().candidates;
|
||||
if (!list.length) { UI.toast('Belum ada data pelamar untuk diexport.', 'err'); return; }
|
||||
try {
|
||||
Excel.exportCandidates(list, Store.get().statuses);
|
||||
UI.toast('File Excel diunduh.', 'ok');
|
||||
} catch (err) { UI.toast('Gagal export Excel: ' + err.message, 'err'); }
|
||||
}
|
||||
|
||||
function doImportExcel(file) {
|
||||
UI.toast('Membaca file Excel...');
|
||||
Excel.readRows(file).then(function (json) {
|
||||
var mapped = Excel.mapRows(json, Store.get().statuses);
|
||||
var valid = [], invalid = 0;
|
||||
mapped.forEach(function (row) {
|
||||
var norm = Phone.normalize(row.phoneRaw || '');
|
||||
if (!row.nama || !Phone.isValid(norm)) { invalid++; return; }
|
||||
row.phoneNorm = norm;
|
||||
valid.push(row);
|
||||
});
|
||||
if (!valid.length) { UI.toast('Tidak ada baris valid. Kolom "Nama" dan "No WhatsApp" wajib terisi.', 'err'); return; }
|
||||
var pesan = 'Ditemukan ' + valid.length + ' baris valid' +
|
||||
(invalid ? ' dan ' + invalid + ' baris dilewati' : '') +
|
||||
'.\n\nTambahkan baris valid ke daftar pelamar?';
|
||||
if (!UI.confirmDialog(pesan)) return;
|
||||
valid.forEach(function (row) { Store.addCandidate(row); });
|
||||
UI.toast(valid.length + ' pelamar ditambahkan.', 'ok');
|
||||
renderInfo();
|
||||
}).catch(function (err) { UI.toast('Gagal import Excel: ' + err.message, 'err'); });
|
||||
}
|
||||
|
||||
function bind() {
|
||||
$('#saveSettings').addEventListener('click', function () {
|
||||
Store.setSettings({
|
||||
perusahaan: $('#s-perusahaan').value.trim(),
|
||||
hr: $('#s-hr').value.trim(),
|
||||
jabatanHr: $('#s-jabatan').value.trim()
|
||||
});
|
||||
UI.toast('Pengaturan disimpan.', 'ok');
|
||||
});
|
||||
|
||||
$('#btnAddStatus').addEventListener('click', function () { openStatusForm(null); });
|
||||
$('#btnExport').addEventListener('click', doExport);
|
||||
$('#btnImport').addEventListener('click', function () { $('#importFile').click(); });
|
||||
$('#importFile').addEventListener('change', function (e) {
|
||||
if (e.target.files && e.target.files[0]) doImport(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
$('#btnExportExcel').addEventListener('click', doExportExcel);
|
||||
$('#btnImportExcel').addEventListener('click', function () { $('#importExcelFile').click(); });
|
||||
$('#btnTemplateExcel').addEventListener('click', function () {
|
||||
try { Excel.exportTemplate(); UI.toast('Template Excel diunduh.', 'ok'); }
|
||||
catch (err) { UI.toast('Gagal mengunduh template: ' + err.message, 'err'); }
|
||||
});
|
||||
$('#importExcelFile').addEventListener('change', function (e) {
|
||||
if (e.target.files && e.target.files[0]) doImportExcel(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
});
|
||||
$('#btnReset').addEventListener('click', function () {
|
||||
if (!UI.confirmDialog('Reset akan menghapus SEMUA data pelamar, template, dan berkas. Lanjutkan?')) return;
|
||||
Store.reset().then(function () {
|
||||
UI.toast('Data telah direset.', 'ok');
|
||||
loadSettings(); renderStatuses(); renderInfo();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var edit = e.target.closest('[data-st-edit]');
|
||||
if (edit) { openStatusForm(edit.getAttribute('data-st-edit')); return; }
|
||||
var del = e.target.closest('[data-st-del]');
|
||||
if (del) {
|
||||
var s = Store.statusById(del.getAttribute('data-st-del'));
|
||||
if (s && UI.confirmDialog('Hapus status "' + s.nama + '"?')) {
|
||||
try { Store.deleteStatus(s.id); UI.toast('Status dihapus.', 'ok'); renderStatuses(); }
|
||||
catch (err) { UI.toast(err.message, 'err'); }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
@@ -0,0 +1,533 @@
|
||||
/* store.js — Lapisan data: localStorage + IndexedDB + Export/Import */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var LS_KEY = 'erecruitment.v1';
|
||||
var FILES_LS_KEY = 'erecruitment.files.v1';
|
||||
var DB_NAME = 'erecruitment-files';
|
||||
var DB_STORE = 'files';
|
||||
var DB_VERSION = 1;
|
||||
|
||||
var DEFAULT_SETTINGS = { perusahaan: 'E-recruitment', hr: '', jabatanHr: '' };
|
||||
|
||||
var DEFAULT_STATUSES = [
|
||||
{ id: 'baru', nama: 'Baru', warna: '#64748b', urutan: 1, isFinal: false },
|
||||
{ id: 'screening', nama: 'Screening', warna: '#3b82f6', urutan: 2, isFinal: false },
|
||||
{ id: 'interview', nama: 'Interview', warna: '#d97706', urutan: 3, isFinal: false },
|
||||
{ id: 'diterima', nama: 'Diterima', warna: '#16a34a', urutan: 4, isFinal: true },
|
||||
{ id: 'ditolak', nama: 'Ditolak', warna: '#dc2626', urutan: 5, isFinal: true },
|
||||
{ id: 'gagal', nama: 'Gagal / Tidak Melanjutkan', warna: '#7c3aed', urutan: 6, isFinal: true }
|
||||
];
|
||||
|
||||
var DEFAULT_TEMPLATES = [
|
||||
{
|
||||
nama: 'Konfirmasi Lamaran', jenis: 'umum', statusId: 'baru', isActive: true,
|
||||
isi: 'Yth. {{nama}},\n\n' +
|
||||
'Terima kasih atas ketertarikan Anda melamar posisi {{posisi}} di {{perusahaan}}. ' +
|
||||
'Lamaran Anda telah kami terima dan akan kami proses sesuai tahapan yang berlaku. ' +
|
||||
'Kami akan menghubungi Anda kembali apabila terdapat pembaruan.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
},
|
||||
{
|
||||
nama: 'Undangan Interview', jenis: 'umum', statusId: 'interview', isActive: true,
|
||||
isi: 'Yth. {{nama}},\n\n' +
|
||||
'Sehubungan dengan lamaran Anda untuk posisi {{posisi}} di {{perusahaan}}, kami mengundang Anda ' +
|
||||
'untuk mengikuti tahap wawancara pada hari {{hari}}, {{tanggal}} pukul {{jam}} ({{metode}}).\n' +
|
||||
'Lokasi/tautan: {{lokasi}}.\n' +
|
||||
'Mohon konfirmasi ketersediaan Anda melalui pesan ini.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
},
|
||||
{
|
||||
nama: 'Reminder Interview', jenis: 'reminder', statusId: 'interview', isActive: true,
|
||||
isi: 'Yth. {{nama}},\n\n' +
|
||||
'Kami mengingatkan kembali jadwal wawancara Anda untuk posisi {{posisi}} di {{perusahaan}} ' +
|
||||
'pada hari {{hari}}, {{tanggal}} pukul {{jam}} ({{metode}}).\n' +
|
||||
'Lokasi/tautan: {{lokasi}}.\n' +
|
||||
'Mohon hadir tepat waktu dan konfirmasi kehadiran Anda melalui pesan ini.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
},
|
||||
{
|
||||
nama: 'Reminder Interview (Pewawancara)', jenis: 'pewawancara', statusId: 'interview', isActive: true,
|
||||
isi: 'Yth. {{pewawancara}},\n\n' +
|
||||
'Kami mengingatkan jadwal wawancara untuk kandidat berikut:\n' +
|
||||
'Nama: {{nama}}\n' +
|
||||
'Posisi: {{posisi}}\n' +
|
||||
'Waktu: {{hari}}, {{tanggal}} pukul {{jam}} ({{metode}})\n' +
|
||||
'Lokasi/tautan: {{lokasi}}\n\n' +
|
||||
'Mohon konfirmasi kesediaan Anda. Terima kasih.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
},
|
||||
{
|
||||
nama: 'Diterima', jenis: 'umum', statusId: 'diterima', isActive: true,
|
||||
isi: 'Yth. {{nama}},\n\n' +
|
||||
'Dengan senang hati kami sampaikan bahwa Anda diterima untuk posisi {{posisi}} di {{perusahaan}}. ' +
|
||||
'Kami akan mengirimkan informasi lebih lanjut mengenai proses selanjutnya.\n' +
|
||||
'Mohon konfirmasi kesediaan Anda melalui pesan ini.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
},
|
||||
{
|
||||
nama: 'Ditolak', jenis: 'umum', statusId: 'ditolak', isActive: true,
|
||||
isi: 'Yth. {{nama}},\n\n' +
|
||||
'Terima kasih atas waktu dan minat Anda melamar posisi {{posisi}} di {{perusahaan}}. ' +
|
||||
'Setelah melalui proses evaluasi, dengan berat hati kami sampaikan bahwa lamaran Anda belum dapat ' +
|
||||
'kami lanjutkan pada kesempatan ini. Kami menyimpan data Anda untuk peluang di masa mendatang.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
},
|
||||
{
|
||||
nama: 'Gagal / Tidak Melanjutkan', jenis: 'umum', statusId: 'gagal', isActive: true,
|
||||
isi: 'Yth. {{nama}},\n\n' +
|
||||
'Kami menindaklanjuti proses lamaran Anda untuk posisi {{posisi}} di {{perusahaan}}. ' +
|
||||
'Berdasarkan catatan kami, proses seleksi Anda tidak dapat dilanjutkan. ' +
|
||||
'Apabila terdapat kekeliruan, silakan menghubungi kami kembali.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
},
|
||||
{
|
||||
nama: 'Follow-up', jenis: 'umum', statusId: null, isActive: true,
|
||||
isi: 'Yth. {{nama}},\n\n' +
|
||||
'Kami ingin menindaklanjuti proses lamaran Anda untuk posisi {{posisi}} di {{perusahaan}}. ' +
|
||||
'Mohon berkenan memberikan konfirmasi atas pesan ini.\n\n' +
|
||||
'Hormat kami,\n{{hr}}\n{{perusahaan}}'
|
||||
}
|
||||
];
|
||||
|
||||
var state = null;
|
||||
var storageOk = true;
|
||||
|
||||
/* ---------- Util ---------- */
|
||||
function uid() {
|
||||
if (global.crypto && global.crypto.randomUUID) {
|
||||
try { return global.crypto.randomUUID(); } catch (e) { /* fallback */ }
|
||||
}
|
||||
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
function seed() {
|
||||
return {
|
||||
version: 1,
|
||||
settings: clone(DEFAULT_SETTINGS),
|
||||
statuses: clone(DEFAULT_STATUSES),
|
||||
templates: DEFAULT_TEMPLATES.map(function (t) {
|
||||
return { id: uid(), nama: t.nama, jenis: t.jenis, statusId: t.statusId, isActive: t.isActive, isi: t.isi };
|
||||
}),
|
||||
candidates: []
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- IndexedDB ---------- */
|
||||
function idbOpen() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!global.indexedDB) return reject(new Error('IndexedDB tidak tersedia'));
|
||||
var req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = function () {
|
||||
var db = req.result;
|
||||
if (!db.objectStoreNames.contains(DB_STORE)) db.createObjectStore(DB_STORE, { keyPath: 'id' });
|
||||
};
|
||||
req.onsuccess = function () { resolve(req.result); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
}
|
||||
|
||||
function idbPut(rec) {
|
||||
return idbOpen().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(DB_STORE, 'readwrite');
|
||||
tx.objectStore(DB_STORE).put(rec);
|
||||
tx.oncomplete = function () { resolve(rec); };
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function idbGet(id) {
|
||||
return idbOpen().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(DB_STORE, 'readonly');
|
||||
var req = tx.objectStore(DB_STORE).get(id);
|
||||
req.onsuccess = function () { resolve(req.result || null); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function idbDelete(id) {
|
||||
return idbOpen().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(DB_STORE, 'readwrite');
|
||||
tx.objectStore(DB_STORE).delete(id);
|
||||
tx.oncomplete = function () { resolve(true); };
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function idbAll() {
|
||||
return idbOpen().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(DB_STORE, 'readonly');
|
||||
var req = tx.objectStore(DB_STORE).getAll();
|
||||
req.onsuccess = function () { resolve(req.result || []); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function blobToDataURL(blob) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var fr = new FileReader();
|
||||
fr.onload = function () { resolve(fr.result); };
|
||||
fr.onerror = function () { reject(fr.error); };
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function dataURLToBlob(dataUrl) {
|
||||
var parts = String(dataUrl).split(',');
|
||||
var mime = (parts[0].match(/:(.*?);/) || [])[1] || 'application/octet-stream';
|
||||
var bin = atob(parts[1]);
|
||||
var arr = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return new Blob([arr], { type: mime });
|
||||
}
|
||||
|
||||
/* ---------- Fallback berkas via localStorage (bila IndexedDB diblokir) ---------- */
|
||||
var FALLBACK_MAX = 2 * 1024 * 1024; // 2 MB per berkas
|
||||
|
||||
function lsFilesRead() {
|
||||
try { return JSON.parse(global.localStorage.getItem(FILES_LS_KEY) || '{}') || {}; }
|
||||
catch (e) { return {}; }
|
||||
}
|
||||
|
||||
function lsFilesWrite(obj) {
|
||||
global.localStorage.setItem(FILES_LS_KEY, JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function putFileRecord(rec) {
|
||||
return idbPut(rec).catch(function () {
|
||||
if (rec.size > FALLBACK_MAX) {
|
||||
throw new Error('IndexedDB tidak tersedia dan berkas lebih dari 2 MB tidak dapat disimpan. Gunakan Link, atau buka aplikasi lewat server lokal.');
|
||||
}
|
||||
return blobToDataURL(rec.blob).then(function (dataUrl) {
|
||||
var all = lsFilesRead();
|
||||
all[rec.id] = { id: rec.id, name: rec.name, type: rec.type, size: rec.size, dataUrl: dataUrl };
|
||||
try { lsFilesWrite(all); }
|
||||
catch (e) { throw new Error('Penyimpanan browser penuh. Gunakan Link atau hapus berkas lain.'); }
|
||||
return rec;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFileRecord(id) {
|
||||
return idbGet(id).catch(function () { return null; }).then(function (rec) {
|
||||
if (rec) return rec;
|
||||
var all = lsFilesRead();
|
||||
return all[id] || null;
|
||||
});
|
||||
}
|
||||
|
||||
function allFileRecords() {
|
||||
return idbAll().catch(function () { return []; }).then(function (rows) {
|
||||
var all = lsFilesRead();
|
||||
var seen = {};
|
||||
var out = [];
|
||||
rows.forEach(function (r) { seen[r.id] = 1; out.push(r); });
|
||||
Object.keys(all).forEach(function (k) { if (!seen[k]) out.push(all[k]); });
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Persistence ---------- */
|
||||
function load() {
|
||||
try {
|
||||
var raw = global.localStorage.getItem(LS_KEY);
|
||||
if (!raw) return seed();
|
||||
var parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return seed();
|
||||
var base = seed();
|
||||
return {
|
||||
version: 1,
|
||||
settings: Object.assign(base.settings, parsed.settings || {}),
|
||||
statuses: Array.isArray(parsed.statuses) && parsed.statuses.length ? parsed.statuses : base.statuses,
|
||||
templates: Array.isArray(parsed.templates) && parsed.templates.length ? parsed.templates : base.templates,
|
||||
candidates: Array.isArray(parsed.candidates) ? parsed.candidates : []
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Gagal memuat data:', e);
|
||||
return seed();
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
global.localStorage.setItem(LS_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
// Penyimpanan tidak tersedia/penuh: jangan crash, pakai memori saja.
|
||||
storageOk = false;
|
||||
console.warn('Penyimpanan browser tidak tersedia / penuh. Data hanya bertahan selama tab terbuka.', e);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
state = load();
|
||||
if (state.candidates.some(function (c) { return !c.id; })) {
|
||||
state.candidates.forEach(function (c) { if (!c.id) c.id = uid(); });
|
||||
}
|
||||
persist();
|
||||
if (!storageOk && global.UI && global.UI.banner) {
|
||||
global.UI.banner('Penyimpanan browser (localStorage) tidak dapat digunakan. Data hanya tersimpan sementara sampai tab ditutup — jangan tutup tab sebelum Export data. Disarankan membuka aplikasi lewat server lokal (lihat README).', 'err');
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function storageAvailable() { return storageOk; }
|
||||
|
||||
function get() { return state; }
|
||||
function save() { persist(); }
|
||||
|
||||
function statusById(id) {
|
||||
return state.statuses.filter(function (s) { return s.id === id; })[0] || null;
|
||||
}
|
||||
function templateById(id) {
|
||||
return state.templates.filter(function (t) { return t.id === id; })[0] || null;
|
||||
}
|
||||
function candidateById(id) {
|
||||
return state.candidates.filter(function (c) { return c.id === id; })[0] || null;
|
||||
}
|
||||
function sortedStatuses() {
|
||||
return state.statuses.slice().sort(function (a, b) { return (a.urutan || 0) - (b.urutan || 0); });
|
||||
}
|
||||
|
||||
/* ---------- Candidates ---------- */
|
||||
function normalizeCandidate(data) {
|
||||
return {
|
||||
nama: (data.nama || '').trim(),
|
||||
phoneRaw: (data.phoneRaw || '').trim(),
|
||||
phoneNorm: data.phoneNorm || '',
|
||||
email: (data.email || '').trim(),
|
||||
posisi: (data.posisi || '').trim(),
|
||||
statusId: data.statusId || (sortedStatuses()[0] ? sortedStatuses()[0].id : 'baru'),
|
||||
sumber: data.sumber || '',
|
||||
domisili: (data.domisili || '').trim(),
|
||||
gajiEkspektasi: (data.gajiEkspektasi || '').trim(),
|
||||
tanggalMelamar: data.tanggalMelamar || new Date().toISOString().slice(0, 10),
|
||||
catatan: (data.catatan || '').trim(),
|
||||
interview: {
|
||||
tanggal: (data.interview && data.interview.tanggal) || '',
|
||||
jam: (data.interview && data.interview.jam) || '',
|
||||
metode: (data.interview && data.interview.metode) || '',
|
||||
lokasi: (data.interview && data.interview.lokasi) || '',
|
||||
pewawancara: (data.interview && data.interview.pewawancara) || '',
|
||||
telpPewawancara: (data.interview && data.interview.telpPewawancara) || ''
|
||||
},
|
||||
berkas: data.berkas || { cv: null, test: null, portofolio: null }
|
||||
};
|
||||
}
|
||||
|
||||
function addCandidate(data) {
|
||||
var now = new Date().toISOString();
|
||||
var c = normalizeCandidate(data);
|
||||
c.id = uid();
|
||||
c.history = [{ statusLama: null, statusBaru: c.statusId, at: now }];
|
||||
c.createdAt = now;
|
||||
c.updatedAt = now;
|
||||
state.candidates.unshift(c);
|
||||
persist();
|
||||
return c;
|
||||
}
|
||||
|
||||
function updateCandidate(id, data) {
|
||||
var c = candidateById(id);
|
||||
if (!c) return null;
|
||||
var before = c.statusId;
|
||||
var next = normalizeCandidate(Object.assign({ berkas: c.berkas }, data));
|
||||
Object.keys(next).forEach(function (k) { c[k] = next[k]; });
|
||||
c.updatedAt = new Date().toISOString();
|
||||
if (before !== c.statusId) {
|
||||
c.history = c.history || [];
|
||||
c.history.push({ statusLama: before, statusBaru: c.statusId, at: c.updatedAt });
|
||||
}
|
||||
persist();
|
||||
return c;
|
||||
}
|
||||
|
||||
function deleteCandidate(id) {
|
||||
var c = candidateById(id);
|
||||
if (!c) return false;
|
||||
['cv', 'test', 'portofolio'].forEach(function (slot) {
|
||||
var b = c.berkas && c.berkas[slot];
|
||||
if (b && b.mode === 'file' && b.fileId) deleteFile(b.fileId).catch(function () {});
|
||||
});
|
||||
state.candidates = state.candidates.filter(function (x) { return x.id !== id; });
|
||||
persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------- Berkas ---------- */
|
||||
function saveFile(file) {
|
||||
var rec = {
|
||||
id: uid(),
|
||||
name: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
blob: file
|
||||
};
|
||||
return putFileRecord(rec).then(function () {
|
||||
return { id: rec.id, name: rec.name, type: rec.type, size: rec.size };
|
||||
});
|
||||
}
|
||||
|
||||
function getFileUrl(fileId) {
|
||||
return getFileRecord(fileId).then(function (rec) {
|
||||
if (!rec) throw new Error('Berkas tidak ditemukan');
|
||||
if (rec.blob) return URL.createObjectURL(rec.blob);
|
||||
if (rec.dataUrl) return rec.dataUrl;
|
||||
throw new Error('Berkas tidak ditemukan');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFile(fileId) {
|
||||
var all = lsFilesRead();
|
||||
if (all[fileId]) {
|
||||
delete all[fileId];
|
||||
try { lsFilesWrite(all); } catch (e) { /* abaikan */ }
|
||||
}
|
||||
return idbDelete(fileId).catch(function () { return true; });
|
||||
}
|
||||
|
||||
function fileCount() {
|
||||
return allFileRecords().then(function (rows) { return rows.length; });
|
||||
}
|
||||
|
||||
/* ---------- Templates ---------- */
|
||||
function addTemplate(data) {
|
||||
var t = {
|
||||
id: uid(),
|
||||
nama: (data.nama || 'Tanpa nama').trim(),
|
||||
jenis: data.jenis || 'umum',
|
||||
statusId: data.statusId || null,
|
||||
isActive: data.isActive !== false,
|
||||
isi: data.isi || ''
|
||||
};
|
||||
state.templates.push(t);
|
||||
persist();
|
||||
return t;
|
||||
}
|
||||
|
||||
function updateTemplate(id, data) {
|
||||
var t = templateById(id);
|
||||
if (!t) return null;
|
||||
if (data.nama != null) t.nama = (data.nama || '').trim();
|
||||
if (data.jenis != null) t.jenis = data.jenis;
|
||||
if (data.statusId !== undefined) t.statusId = data.statusId || null;
|
||||
if (data.isActive != null) t.isActive = !!data.isActive;
|
||||
if (data.isi != null) t.isi = data.isi;
|
||||
persist();
|
||||
return t;
|
||||
}
|
||||
|
||||
function deleteTemplate(id) {
|
||||
state.templates = state.templates.filter(function (t) { return t.id !== id; });
|
||||
persist();
|
||||
}
|
||||
|
||||
/* ---------- Settings / Statuses ---------- */
|
||||
function setSettings(patch) {
|
||||
Object.assign(state.settings, patch);
|
||||
persist();
|
||||
}
|
||||
|
||||
function saveStatus(status) {
|
||||
if (status.id && statusById(status.id)) {
|
||||
var s = statusById(status.id);
|
||||
s.nama = status.nama;
|
||||
s.warna = status.warna;
|
||||
s.urutan = status.urutan != null ? status.urutan : s.urutan;
|
||||
s.isFinal = !!status.isFinal;
|
||||
} else {
|
||||
var maxUrut = state.statuses.reduce(function (m, x) { return Math.max(m, x.urutan || 0); }, 0);
|
||||
state.statuses.push({
|
||||
id: status.id || uid(),
|
||||
nama: status.nama,
|
||||
warna: status.warna || '#64748b',
|
||||
urutan: status.urutan != null ? status.urutan : maxUrut + 1,
|
||||
isFinal: !!status.isFinal
|
||||
});
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
function deleteStatus(id) {
|
||||
if (state.candidates.some(function (c) { return c.statusId === id; })) {
|
||||
throw new Error('Status masih dipakai oleh pelamar.');
|
||||
}
|
||||
state.statuses = state.statuses.filter(function (s) { return s.id !== id; });
|
||||
state.templates.forEach(function (t) { if (t.statusId === id) t.statusId = null; });
|
||||
persist();
|
||||
}
|
||||
|
||||
/* ---------- Export / Import ---------- */
|
||||
function exportData() {
|
||||
return allFileRecords().then(function (rows) {
|
||||
return Promise.all(rows.map(function (r) {
|
||||
var asUrl = r.dataUrl ? Promise.resolve(r.dataUrl) : blobToDataURL(r.blob);
|
||||
return asUrl.then(function (dataUrl) {
|
||||
return { id: r.id, name: r.name, type: r.type, size: r.size, dataUrl: dataUrl };
|
||||
});
|
||||
})).then(function (files) {
|
||||
return {
|
||||
app: 'e-recruitment',
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings: state.settings,
|
||||
statuses: state.statuses,
|
||||
templates: state.templates,
|
||||
candidates: state.candidates,
|
||||
files: files
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function importData(payload) {
|
||||
if (!payload || payload.app !== 'e-recruitment') {
|
||||
return Promise.reject(new Error('File backup tidak dikenali.'));
|
||||
}
|
||||
var files = payload.files || [];
|
||||
return Promise.all(files.map(function (f) {
|
||||
return dataURLToBlob(f.dataUrl).then(function (blob) {
|
||||
return putFileRecord({ id: f.id, name: f.name, type: f.type, size: f.size, blob: blob });
|
||||
});
|
||||
})).then(function () {
|
||||
state = {
|
||||
version: 1,
|
||||
settings: Object.assign(clone(DEFAULT_SETTINGS), payload.settings || {}),
|
||||
statuses: Array.isArray(payload.statuses) && payload.statuses.length ? payload.statuses : clone(DEFAULT_STATUSES),
|
||||
templates: Array.isArray(payload.templates) ? payload.templates : [],
|
||||
candidates: Array.isArray(payload.candidates) ? payload.candidates : []
|
||||
};
|
||||
persist();
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state = seed();
|
||||
persist();
|
||||
try { lsFilesWrite({}); } catch (e) { /* abaikan */ }
|
||||
return allFileRecords().then(function (rows) {
|
||||
return Promise.all(rows.map(function (r) { return deleteFile(r.id); }));
|
||||
}).then(function () { return state; });
|
||||
}
|
||||
|
||||
global.Store = {
|
||||
init: init, get: get, save: save, seed: seed, uid: uid, storageAvailable: storageAvailable,
|
||||
statusById: statusById, templateById: templateById, candidateById: candidateById,
|
||||
sortedStatuses: sortedStatuses,
|
||||
addCandidate: addCandidate, updateCandidate: updateCandidate, deleteCandidate: deleteCandidate,
|
||||
saveFile: saveFile, getFileUrl: getFileUrl, deleteFile: deleteFile, fileCount: fileCount,
|
||||
addTemplate: addTemplate, updateTemplate: updateTemplate, deleteTemplate: deleteTemplate,
|
||||
setSettings: setSettings, saveStatus: saveStatus, deleteStatus: deleteStatus,
|
||||
exportData: exportData, importData: importData, reset: reset
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,177 @@
|
||||
/* templates.js — Halaman manajemen template pesan */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (s, r) { return (r || document).querySelector(s); };
|
||||
|
||||
function esc(v) { return UI.escape(v); }
|
||||
|
||||
function sampleContext() {
|
||||
return {
|
||||
nama: 'Budi Santoso',
|
||||
posisi: 'Staff Administrasi',
|
||||
perusahaan: Store.get().settings.perusahaan || 'E-recruitment',
|
||||
status: 'Interview',
|
||||
tanggal: '22 September 2025',
|
||||
hari: 'Senin',
|
||||
tanggal_lengkap: 'Senin, 22 September 2025',
|
||||
tanggal_melamar: '10 September 2025',
|
||||
jam: '10:00',
|
||||
metode: 'Online',
|
||||
lokasi: 'Google Meet',
|
||||
pewawancara: 'Andi Wijaya',
|
||||
hr: Store.get().settings.hr || 'HRD',
|
||||
jabatan_hr: Store.get().settings.jabatanHr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function init() {
|
||||
Store.init();
|
||||
render();
|
||||
$('#btnAddTpl').addEventListener('click', function () { openForm(null); });
|
||||
}
|
||||
|
||||
function statusName(id) {
|
||||
var s = Store.statusById(id);
|
||||
return s ? s.nama : null;
|
||||
}
|
||||
|
||||
function render() {
|
||||
var list = Store.get().templates;
|
||||
var wrap = $('#tplList');
|
||||
if (!list.length) {
|
||||
wrap.innerHTML = '';
|
||||
$('#tplEmpty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
$('#tplEmpty').classList.add('hidden');
|
||||
|
||||
wrap.innerHTML = list.map(function (t) {
|
||||
var st = statusName(t.statusId);
|
||||
var meta = [];
|
||||
var jenisLabel = t.jenis === 'reminder' ? 'Reminder' : (t.jenis === 'pewawancara' ? 'Pewawancara' : 'Umum');
|
||||
var jenisWarna = t.jenis === 'reminder' ? '#d97706' : (t.jenis === 'pewawancara' ? '#7c3aed' : '#2563eb');
|
||||
meta.push('<span class="badge" style="background:' + jenisWarna + '">' + jenisLabel + '</span>');
|
||||
if (st) meta.push('<span class="muted small">Status: ' + esc(st) + '</span>');
|
||||
meta.push('<span class="' + (t.isActive ? '' : 'muted') + ' small">' + (t.isActive ? 'Aktif' : 'Nonaktif') + '</span>');
|
||||
|
||||
return '<div class="template-item">' +
|
||||
'<div class="ti-head">' +
|
||||
'<div><strong>' + esc(t.nama) + '</strong><div class="mt1">' + meta.join(' ') + '</div></div>' +
|
||||
'<div class="actions">' +
|
||||
'<button class="btn btn-ghost btn-sm" data-tpl-toggle="' + t.id + '">' + (t.isActive ? 'Nonaktifkan' : 'Aktifkan') + '</button>' +
|
||||
'<button class="btn btn-ghost btn-sm" data-tpl-edit="' + t.id + '">Edit</button>' +
|
||||
'<button class="btn btn-danger btn-sm" data-tpl-del="' + t.id + '">Hapus</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<pre>' + esc(t.isi) + '</pre>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function statusOptions(selected) {
|
||||
return '<option value="">- Tidak terkait status -</option>' +
|
||||
Store.sortedStatuses().map(function (s) {
|
||||
return '<option value="' + esc(s.id) + '"' + (s.id === selected ? ' selected' : '') + '>' + esc(s.nama) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openForm(id) {
|
||||
var t = id ? Store.templateById(id) : null;
|
||||
var chips = WA.PLACEHOLDERS.map(function (p) {
|
||||
return '<span class="chip" data-ins="' + p + '">{{' + p + '}}</span>';
|
||||
}).join('');
|
||||
|
||||
var body =
|
||||
'<div class="grid cols-2">' +
|
||||
'<div class="field"><label for="t-nama">Nama template *</label><input type="text" id="t-nama" value="' + esc(t ? t.nama : '') + '"></div>' +
|
||||
'<div class="field"><label for="t-jenis">Jenis</label><select id="t-jenis">' +
|
||||
'<option value="umum"' + (t && t.jenis === 'umum' ? ' selected' : '') + '>Umum</option>' +
|
||||
'<option value="reminder"' + (t && t.jenis === 'reminder' ? ' selected' : '') + '>Reminder Interview (Pelamar)</option>' +
|
||||
'<option value="pewawancara"' + (t && t.jenis === 'pewawancara' ? ' selected' : '') + '>Reminder Interview (Pewawancara)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="grid cols-2">' +
|
||||
'<div class="field"><label for="t-status">Terkait status</label><select id="t-status">' + statusOptions(t ? t.statusId : '') + '</select></div>' +
|
||||
'<div class="field"><label for="t-active">Status template</label><select id="t-active">' +
|
||||
'<option value="1"' + (!t || t.isActive ? ' selected' : '') + '>Aktif</option>' +
|
||||
'<option value="0"' + (t && !t.isActive ? ' selected' : '') + '>Nonaktif</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
'<div class="field"><label>Placeholder (klik untuk menyisipkan)</label><div class="placeholder-btns">' + chips + '</div></div>' +
|
||||
'<div class="field"><label for="t-isi">Isi pesan *</label><textarea id="t-isi" style="min-height:200px">' + esc(t ? t.isi : '') + '</textarea></div>' +
|
||||
'<div class="field"><label>Preview (contoh data)</label><div class="preview-box" id="t-preview"></div></div>';
|
||||
|
||||
var footer =
|
||||
'<button class="btn btn-ghost" type="button" data-close>Batal</button>' +
|
||||
'<button class="btn btn-primary" type="button" id="saveTpl">' + (id ? 'Simpan Perubahan' : 'Tambah Template') + '</button>';
|
||||
|
||||
UI.openModal({
|
||||
title: id ? 'Edit Template' : 'Tambah Template',
|
||||
size: 'lg',
|
||||
body: body,
|
||||
footer: footer,
|
||||
onMount: function (el) {
|
||||
var isi = el.querySelector('#t-isi');
|
||||
var prev = el.querySelector('#t-preview');
|
||||
function update() { prev.textContent = WA.render(isi.value, sampleContext()); }
|
||||
isi.addEventListener('input', update);
|
||||
update();
|
||||
|
||||
el.querySelectorAll('[data-ins]').forEach(function (chip) {
|
||||
chip.addEventListener('click', function () {
|
||||
var text = '{{' + chip.getAttribute('data-ins') + '}}';
|
||||
var start = isi.selectionStart, end = isi.selectionEnd;
|
||||
isi.value = isi.value.slice(0, start) + text + isi.value.slice(end);
|
||||
isi.selectionStart = isi.selectionEnd = start + text.length;
|
||||
isi.focus();
|
||||
update();
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelector('[data-close]').addEventListener('click', UI.closeModal);
|
||||
el.querySelector('#saveTpl').addEventListener('click', function () {
|
||||
var nama = el.querySelector('#t-nama').value.trim();
|
||||
var isiVal = isi.value;
|
||||
if (!nama) { UI.toast('Nama template wajib diisi.', 'err'); return; }
|
||||
if (!isiVal.trim()) { UI.toast('Isi pesan wajib diisi.', 'err'); return; }
|
||||
var data = {
|
||||
nama: nama,
|
||||
jenis: el.querySelector('#t-jenis').value,
|
||||
statusId: el.querySelector('#t-status').value || null,
|
||||
isActive: el.querySelector('#t-active').value === '1',
|
||||
isi: isiVal
|
||||
};
|
||||
if (id) { Store.updateTemplate(id, data); UI.toast('Template diperbarui.', 'ok'); }
|
||||
else { Store.addTemplate(data); UI.toast('Template ditambahkan.', 'ok'); }
|
||||
UI.closeModal();
|
||||
render();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var edit = e.target.closest('[data-tpl-edit]');
|
||||
if (edit) { openForm(edit.getAttribute('data-tpl-edit')); return; }
|
||||
|
||||
var del = e.target.closest('[data-tpl-del]');
|
||||
if (del) {
|
||||
var t = Store.templateById(del.getAttribute('data-tpl-del'));
|
||||
if (t && UI.confirmDialog('Hapus template "' + t.nama + '"?')) {
|
||||
Store.deleteTemplate(t.id);
|
||||
UI.toast('Template dihapus.', 'ok');
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var tog = e.target.closest('[data-tpl-toggle]');
|
||||
if (tog) {
|
||||
var tt = Store.templateById(tog.getAttribute('data-tpl-toggle'));
|
||||
if (tt) { Store.updateTemplate(tt.id, { isActive: !tt.isActive }); render(); }
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/* ui.js — Helper tampilan: escape, format, badge, modal, toast, confirm */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function escape(v) {
|
||||
if (v == null) return '';
|
||||
return String(v)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
return global.WA ? global.WA.tanggalPanjang(iso) : (iso || '');
|
||||
}
|
||||
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return '';
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
var jam = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
return formatDate(iso) + ' ' + jam;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes && bytes !== 0) return '';
|
||||
var units = ['B', 'KB', 'MB', 'GB'];
|
||||
var i = 0;
|
||||
var n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||
return (i === 0 ? n : n.toFixed(1)) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function badge(status) {
|
||||
if (!status) return '';
|
||||
return '<span class="badge" style="background:' + escape(status.warna || '#64748b') + '">' +
|
||||
escape(status.nama) + '</span>';
|
||||
}
|
||||
|
||||
function escapeAttr(v) { return escape(v); }
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
function toastBox() {
|
||||
var box = document.getElementById('toastBox');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = 'toastBox';
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function toast(message, type) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'toast' + (type === 'ok' ? ' ok' : type === 'err' ? ' err' : '');
|
||||
el.textContent = message;
|
||||
toastBox().appendChild(el);
|
||||
setTimeout(function () {
|
||||
el.style.transition = 'opacity .25s';
|
||||
el.style.opacity = '0';
|
||||
setTimeout(function () { el.remove(); }, 260);
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
var modalEl = null;
|
||||
|
||||
function ensureModal() {
|
||||
if (modalEl) return modalEl;
|
||||
modalEl = document.createElement('div');
|
||||
modalEl.className = 'modal-overlay';
|
||||
modalEl.innerHTML =
|
||||
'<div class="modal" role="dialog" aria-modal="true">' +
|
||||
'<div class="modal-head"><h3></h3><button class="modal-x" type="button" aria-label="Tutup">×</button></div>' +
|
||||
'<div class="modal-body"></div>' +
|
||||
'<div class="modal-foot"></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(modalEl);
|
||||
modalEl.addEventListener('click', function (e) {
|
||||
if (e.target === modalEl) closeModal();
|
||||
});
|
||||
modalEl.querySelector('.modal-x').addEventListener('click', closeModal);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && modalEl && modalEl.parentNode) closeModal();
|
||||
});
|
||||
return modalEl;
|
||||
}
|
||||
|
||||
function openModal(opts) {
|
||||
var el = ensureModal();
|
||||
opts = opts || {};
|
||||
var modal = el.querySelector('.modal');
|
||||
modal.className = 'modal' + (opts.size === 'lg' ? ' lg' : '');
|
||||
el.querySelector('.modal-head h3').textContent = opts.title || '';
|
||||
el.querySelector('.modal-body').innerHTML = opts.body || '';
|
||||
el.querySelector('.modal-foot').innerHTML = opts.footer || '';
|
||||
el.querySelector('.modal-foot').style.display = opts.footer ? 'flex' : 'none';
|
||||
el.style.display = 'flex';
|
||||
if (typeof opts.onMount === 'function') opts.onMount(el);
|
||||
var firstInput = el.querySelector('.modal-body input, .modal-body select, .modal-body textarea');
|
||||
if (firstInput) setTimeout(function () { firstInput.focus(); }, 50);
|
||||
return el;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (modalEl) modalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function isModalOpen() {
|
||||
return !!(modalEl && modalEl.style.display !== 'none');
|
||||
}
|
||||
|
||||
function confirmDialog(message, title) {
|
||||
return global.confirm((title ? title + '\n\n' : '') + message);
|
||||
}
|
||||
|
||||
/* ---------- Banner peringatan ---------- */
|
||||
function banner(message, type) {
|
||||
if (!document.body) return;
|
||||
var host = document.querySelector('main.container') || document.body;
|
||||
var existing = document.getElementById('appBanner');
|
||||
if (existing) existing.remove();
|
||||
var el = document.createElement('div');
|
||||
el.id = 'appBanner';
|
||||
var base = 'padding:10px 14px;border-radius:10px;margin-bottom:14px;font-size:13px;line-height:1.5;';
|
||||
if (type === 'warn') {
|
||||
el.style.cssText = base + 'background:#fffbeb;border:1px solid #fde68a;color:#92400e;';
|
||||
} else {
|
||||
el.style.cssText = base + 'background:#fef2f2;border:1px solid #fecaca;color:#991b1b;';
|
||||
}
|
||||
el.textContent = message;
|
||||
host.insertBefore(el, host.firstChild);
|
||||
}
|
||||
|
||||
// Tampilkan error apa pun sebagai banner, bukan halaman kosong.
|
||||
global.addEventListener('error', function (e) {
|
||||
var msg = (e && (e.message || (e.error && e.error.message))) || 'Kesalahan tidak diketahui';
|
||||
try { banner('Terjadi kesalahan: ' + msg, 'err'); } catch (_) { /* abaikan */ }
|
||||
});
|
||||
global.addEventListener('unhandledrejection', function (e) {
|
||||
var r = e && e.reason;
|
||||
var msg = (r && (r.message || r)) || 'Operasi gagal';
|
||||
try { banner('Terjadi kesalahan: ' + msg, 'err'); } catch (_) { /* abaikan */ }
|
||||
});
|
||||
|
||||
global.UI = {
|
||||
escape: escape, escapeAttr: escapeAttr, formatDate: formatDate, formatDateTime: formatDateTime,
|
||||
formatBytes: formatBytes, badge: badge, toast: toast, banner: banner,
|
||||
openModal: openModal, closeModal: closeModal, isModalOpen: isModalOpen,
|
||||
confirmDialog: confirmDialog
|
||||
};
|
||||
})(window);
|
||||
Vendored
+22
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
/* whatsapp.js — Format tanggal, render template, dan buat link wa.me */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var HARI = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
|
||||
var BULAN = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
|
||||
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
|
||||
function parseISO(iso) {
|
||||
if (!iso) return null;
|
||||
var d = new Date(String(iso).length <= 10 ? iso + 'T00:00:00' : iso);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function namaHari(iso) {
|
||||
var d = parseISO(iso);
|
||||
return d ? HARI[d.getDay()] : '';
|
||||
}
|
||||
|
||||
// 22 September 2025
|
||||
function tanggalPanjang(iso) {
|
||||
var d = parseISO(iso);
|
||||
if (!d) return '';
|
||||
return d.getDate() + ' ' + BULAN[d.getMonth()] + ' ' + d.getFullYear();
|
||||
}
|
||||
|
||||
function tanggalLengkap(iso) {
|
||||
var d = parseISO(iso);
|
||||
if (!d) return '';
|
||||
return HARI[d.getDay()] + ', ' + d.getDate() + ' ' + BULAN[d.getMonth()] + ' ' + d.getFullYear();
|
||||
}
|
||||
|
||||
function context(candidate, settings) {
|
||||
var s = settings || {};
|
||||
var iv = candidate.interview || {};
|
||||
var tanggalSumber = iv.tanggal || candidate.tanggalMelamar || '';
|
||||
return {
|
||||
nama: candidate.nama || '',
|
||||
posisi: candidate.posisi || '',
|
||||
perusahaan: s.perusahaan || 'E-recruitment',
|
||||
status: candidate.statusNama || '',
|
||||
tanggal: tanggalPanjang(tanggalSumber),
|
||||
hari: namaHari(tanggalSumber),
|
||||
tanggal_lengkap: tanggalLengkap(tanggalSumber),
|
||||
tanggal_melamar: tanggalPanjang(candidate.tanggalMelamar),
|
||||
jam: iv.jam || '',
|
||||
metode: iv.metode || '',
|
||||
lokasi: iv.lokasi || '',
|
||||
pewawancara: iv.pewawancara || '',
|
||||
hr: s.hr || '',
|
||||
jabatan_hr: s.jabatanHr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function render(isi, ctx) {
|
||||
return String(isi || '').replace(/\{\{\s*([a-zA-Z_]+)\s*\}\}/g, function (match, key) {
|
||||
var k = key.toLowerCase();
|
||||
return Object.prototype.hasOwnProperty.call(ctx, k) ? String(ctx[k]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
function buildLink(phoneNorm, message) {
|
||||
return 'https://wa.me/' + phoneNorm + '?text=' + encodeURIComponent(message || '');
|
||||
}
|
||||
|
||||
function open(phoneNorm, message) {
|
||||
var w = global.open(buildLink(phoneNorm, message), '_blank', 'noopener');
|
||||
if (w) w.focus();
|
||||
}
|
||||
|
||||
var PLACEHOLDERS = [
|
||||
'nama', 'posisi', 'perusahaan', 'status', 'tanggal', 'hari', 'jam',
|
||||
'metode', 'lokasi', 'pewawancara', 'hr', 'jabatan_hr'
|
||||
];
|
||||
|
||||
global.WA = {
|
||||
render: render, context: context, buildLink: buildLink, open: open,
|
||||
namaHari: namaHari, tanggalPanjang: tanggalPanjang, tanggalLengkap: tanggalLengkap,
|
||||
PLACEHOLDERS: PLACEHOLDERS
|
||||
};
|
||||
})(window);
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Berkas Pelamar — E-recruitment</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/img/logo.svg">
|
||||
<link rel="stylesheet" href="assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<span class="logo-mark">E</span>
|
||||
<span class="logo-text">E-recruitment</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="index.html">Pelamar</a>
|
||||
<a href="template.html">Template Pesan</a>
|
||||
<a href="berkas.html" class="active">Berkas</a>
|
||||
<a href="pengaturan.html">Pengaturan</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Berkas Pelamar</h1>
|
||||
<p class="muted">CV, hasil test, dan portofolio. Unggah file (maks. 5 MB) atau tempel link, langsung dari halaman ini.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="grow"><input type="search" id="bSearch" placeholder="Cari nama / posisi..."></div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Nama / Posisi</th><th>CV</th><th>Hasil Test</th><th>Portofolio</th></tr>
|
||||
</thead>
|
||||
<tbody id="bBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty hidden" id="bEmpty">Belum ada pelamar. Tambahkan pelamar dan lampirkan berkasnya di halaman Pelamar.</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/store.js"></script>
|
||||
<script src="assets/js/phone.js"></script>
|
||||
<script src="assets/js/whatsapp.js"></script>
|
||||
<script src="assets/js/ui.js"></script>
|
||||
<script src="assets/js/berkas.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Daftar Pelamar — E-recruitment</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/img/logo.svg">
|
||||
<link rel="stylesheet" href="assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<span class="logo-mark">E</span>
|
||||
<span class="logo-text">E-recruitment</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="index.html" class="active">Pelamar</a>
|
||||
<a href="template.html">Template Pesan</a>
|
||||
<a href="berkas.html">Berkas</a>
|
||||
<a href="pengaturan.html">Pengaturan</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Daftar Pelamar</h1>
|
||||
<p class="muted">Kelola data pelamar, status rekrutmen, dan hubungi via WhatsApp.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="btnAdd">+ Tambah Pelamar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="summary" id="summary"></section>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="grow">
|
||||
<input type="search" id="fSearch" placeholder="Cari nama / no. HP / posisi...">
|
||||
</div>
|
||||
<select id="fStatus" style="width:auto"></select>
|
||||
<select id="fPosisi" style="width:auto"></select>
|
||||
<select id="fSumber" style="width:auto"></select>
|
||||
<select id="fSort" style="width:auto">
|
||||
<option value="tanggal-desc">Terbaru</option>
|
||||
<option value="tanggal-asc">Terlama</option>
|
||||
<option value="nama-asc">Nama A-Z</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" id="btnExportExcel">Export Excel</button>
|
||||
<button class="btn btn-ghost" id="btnImportExcel">Import Excel</button>
|
||||
<input type="file" id="importExcelFile" accept=".xlsx,.xls,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,text/csv" hidden>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nama</th>
|
||||
<th>Posisi</th>
|
||||
<th>No. WhatsApp</th>
|
||||
<th>Status</th>
|
||||
<th>Tgl Melamar</th>
|
||||
<th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty hidden" id="emptyState">
|
||||
Belum ada data pelamar. Klik <strong>+ Tambah Pelamar</strong> untuk memulai.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/store.js"></script>
|
||||
<script src="assets/js/phone.js"></script>
|
||||
<script src="assets/js/whatsapp.js"></script>
|
||||
<script src="assets/js/ui.js"></script>
|
||||
<script src="assets/js/vendor/xlsx.full.min.js"></script>
|
||||
<script src="assets/js/excel.js"></script>
|
||||
<script src="assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pengaturan — E-recruitment</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/img/logo.svg">
|
||||
<link rel="stylesheet" href="assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<span class="logo-mark">E</span>
|
||||
<span class="logo-text">E-recruitment</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="index.html">Pelamar</a>
|
||||
<a href="template.html">Template Pesan</a>
|
||||
<a href="berkas.html">Berkas</a>
|
||||
<a href="pengaturan.html" class="active">Pengaturan</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Pengaturan</h1>
|
||||
<p class="muted">Data perusahaan, HR, status rekrutmen, dan pengelolaan data.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Data Perusahaan & HR</h3>
|
||||
<div class="grid cols-3">
|
||||
<div class="field"><label for="s-perusahaan">Nama perusahaan</label><input type="text" id="s-perusahaan"></div>
|
||||
<div class="field"><label for="s-hr">Nama HR</label><input type="text" id="s-hr" placeholder="cth: Siti Aminah"></div>
|
||||
<div class="field"><label for="s-jabatan">Jabatan HR</label><input type="text" id="s-jabatan" placeholder="cth: HRD Manager"></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="saveSettings">Simpan Pengaturan</button>
|
||||
<div class="hint mt1">Nama perusahaan & HR dipakai pada tanda tangan template pesan.</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="page-head">
|
||||
<div><h3>Status Rekrutmen</h3><p class="muted">Urutan status menentukan tampilan ringkasan.</p></div>
|
||||
<button class="btn btn-ghost" id="btnAddStatus">+ Tambah Status</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Urutan</th><th>Nama</th><th>Warna</th><th>Final</th><th>Aksi</th></tr></thead>
|
||||
<tbody id="statusBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Data</h3>
|
||||
<p class="muted">Export ke Excel untuk dibuka di spreadsheet, atau Backup JSON untuk cadangan lengkap (termasuk berkas).</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="btnExportExcel">Export Excel</button>
|
||||
<button class="btn btn-ghost" id="btnImportExcel">Import Excel</button>
|
||||
<button class="btn btn-ghost" id="btnTemplateExcel">Unduh Template Excel</button>
|
||||
<input type="file" id="importExcelFile" accept=".xlsx,.xls,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,text/csv" hidden>
|
||||
</div>
|
||||
<div class="actions mt1">
|
||||
<button class="btn btn-ghost" id="btnExport">Backup JSON</button>
|
||||
<button class="btn btn-ghost" id="btnImport">Restore JSON</button>
|
||||
<button class="btn btn-danger" id="btnReset">Reset Semua Data</button>
|
||||
<input type="file" id="importFile" accept="application/json,.json" hidden>
|
||||
</div>
|
||||
<div class="hint mt2" id="dataInfo"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/store.js"></script>
|
||||
<script src="assets/js/phone.js"></script>
|
||||
<script src="assets/js/whatsapp.js"></script>
|
||||
<script src="assets/js/ui.js"></script>
|
||||
<script src="assets/js/vendor/xlsx.full.min.js"></script>
|
||||
<script src="assets/js/excel.js"></script>
|
||||
<script src="assets/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
# Riwayat Percakapan — Proyek E-recruitment (VC-Final)
|
||||
|
||||
> Transkrip diskusi & pengerjaan proyek web rekrutmen karyawan.
|
||||
> Diekspor dari sesi agen. Tanggal sesi: 18 September (proyek di `C:/VC-Final`).
|
||||
|
||||
---
|
||||
|
||||
## Turn 1
|
||||
|
||||
**User:**
|
||||
|
||||
> kita diskusi dulu ya, jangan buat aplikasinya dulu
|
||||
> saya ingin membuat web tentang reqrutment karyawan, dimana ada informasi karyawan, status, dan setiap informasi no hp akan ada direct ke wa.
|
||||
> Ketika direct ke wa juga akan ada template yang sudah disediakan, buat agen.md nya dahulu ya
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Membuat `agen.md` berisi: ringkasan proyek, tujuan, ruang lingkup (MVP vs lanjutan), persona, alur user, detail fitur, contoh template WA, model data, keamanan, opsi tech stack, struktur folder, konvensi, roadmap, pertanyaan terbuka, dan definisi selesai. Lalu mengajukan pertanyaan kunci (tech stack, cara kirim WA, status, template, login).
|
||||
|
||||
---
|
||||
|
||||
## Turn 2
|
||||
|
||||
**User:**
|
||||
|
||||
> 1. buat yang simpe saja versi web
|
||||
> 2. tinggal kirim dari user
|
||||
> 3. ada status diterima, ditolak, gagal atau tidak melanjutkan
|
||||
> 4. buat pesan formal by template HR saja
|
||||
> 5. akan dipakai HR saja
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Menyusun ulang `agen.md` menjadi spec versi simpel: web statis, WA dikirim manual, status awal (Baru/Diterima/Ditolak/Gagal), pesan formal, single user. Menanyakan 5 hal lanjutan (penyimpanan, nama perusahaan/HR, status tambahan, field data, struktur file).
|
||||
|
||||
---
|
||||
|
||||
## Turn 3
|
||||
|
||||
**User:**
|
||||
|
||||
> 1. pakai local storage, tapi tambhakan export di data nya
|
||||
> 2. E-recruitment
|
||||
> 3. boleh
|
||||
> 4. pisah
|
||||
>
|
||||
> ohya, tambahkan juga remainder ke orang yang akan interview pesan wa dengan template remainder ada interview di jam sekian hari sekian, lalu tambahkan juga ada info upload cv , hasil test, dan portofolio ya
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Finalisasi `agen.md`:
|
||||
- Penyimpanan localStorage + Export/Import JSON
|
||||
- Nama perusahaan **E-recruitment**
|
||||
- Status 6: Baru → Screening → Interview → Diterima → Ditolak → Gagal/Tidak Melanjutkan
|
||||
- Jadwal interview + tombol Reminder Interview
|
||||
- Berkas: CV, Hasil Test, Portofolio
|
||||
- Struktur file dipisah per halaman
|
||||
- Menjelaskan trade-off berkas (link vs upload; IndexedDB untuk file).
|
||||
|
||||
---
|
||||
|
||||
## Turn 4
|
||||
|
||||
**User:**
|
||||
|
||||
> boleh dilanjutkan
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Membangun aplikasi MVP. Struktur yang dibuat:
|
||||
|
||||
```
|
||||
C:/VC-Final/
|
||||
├─ index.html # daftar pelamar + ringkasan + form + detail + WA + berkas
|
||||
├─ template.html # kelola template pesan WhatsApp
|
||||
├─ berkas.html # lihat berkas pelamar
|
||||
├─ pengaturan.html # data perusahaan/HR, status, export/import/reset
|
||||
├─ agen.md
|
||||
├─ README.md
|
||||
└─ assets/
|
||||
├─ css/style.css
|
||||
└─ js/ store.js, phone.js, whatsapp.js, ui.js, app.js, templates.js, settings.js, berkas.js
|
||||
```
|
||||
|
||||
Fitur: CRUD pelamar, normalisasi no HP, status + riwayat, tombol WA + template + preview, 7 template formal, berkas, pencarian/filter, export/import JSON, pengaturan status. Semua JS lolos `node --check`. Menandai roadmap selesai.
|
||||
|
||||
---
|
||||
|
||||
## Turn 5
|
||||
|
||||
**User:**
|
||||
|
||||
> halaman pelamar gabisa dibuka
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Menelusuri: lewat server HTTP halaman jalan; blank terjadi saat `localStorage` diblokir (umum di `file://`), karena `persist()` melempar error dan `init()` ikut berhenti.
|
||||
|
||||
Perbaikan:
|
||||
- `store.js`: `persist()` tidak melempar error lagi; fallback ke memori + flag `storageOk`.
|
||||
- `ui.js`: fungsi `banner()` + penangkap error global (`error`, `unhandledrejection`), supaya error tampil sebagai pesan, bukan halaman kosong.
|
||||
|
||||
Hasil uji: skenario `file://` tetap menampilkan halaman + banner peringatan; via HTTP semua normal.
|
||||
|
||||
---
|
||||
|
||||
## Turn 6
|
||||
|
||||
**User:**
|
||||
|
||||
> untuk berkas belum bisa diupload atau uploadnya seperti apa ?
|
||||
> lalu reminder juga dibutuhkan ke pewawancara sebagai remainder
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Perbaikan & penambahan:
|
||||
- **Berkas**: halaman Berkas kini punya tombol Upload/Link/Lihat/Hapus langsung. Penyimpanan IndexedDB dengan **fallback localStorage base64** (maks 2 MB) bila IndexedDB diblokir.
|
||||
- **Reminder Pewawancara**: field baru "Nama pewawancara" + "No. WA pewawancara"; tombol Reminder Pewawancara di modal Detail; template baru jenis *Reminder Interview (Pewawancara)*; placeholder `{{pewawancara}}`.
|
||||
|
||||
Hasil uji: upload → buka → hapus sukses; reminder mengarah ke nomor pewawancara (`wa.me/6281299998888`).
|
||||
|
||||
---
|
||||
|
||||
## Turn 7
|
||||
|
||||
**User:**
|
||||
|
||||
> apakah reminder pelamar dan kriim whatssap sama saja featurenya ?
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Menjelaskan bahwa mekanismenya sama (dialog template + preview → `wa.me`), bedanya hanya template yang otomatis terpilih. Reminder Pewawancara berbeda karena nomor tujuannya nomor pewawancara. Menawarkan penyederhanaan UI (Opsi A: satu tombol; Opsi B: pertahankan dua tombol).
|
||||
|
||||
---
|
||||
|
||||
## Turn 8
|
||||
|
||||
**User:**
|
||||
|
||||
> buat hanya remainder pewawancara dan remainder pelamar
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Menghapus tombol "Kirim WhatsApp" umum. Sekarang:
|
||||
- Tabel: `Reminder Pelamar`, `Detail`, `Edit`, `Hapus`
|
||||
- Modal Detail: `Tutup`, `Reminder Pelamar`, `Reminder Pewawancara`, `Edit`
|
||||
|
||||
Ditambah konfirmasi bila jadwal interview belum diisi. Hasil uji sesuai.
|
||||
|
||||
---
|
||||
|
||||
## Turn 9
|
||||
|
||||
**User:**
|
||||
|
||||
> untuk data pelamar buat bisa diupload ke excel juga ya
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Menambahkan dukungan Excel (SheetJS di-*vendor* lokal `assets/js/vendor/xlsx.full.min.js`, ±860 KB, agar offline):
|
||||
- `assets/js/excel.js` — export `.xlsx`, import `.xlsx/.xls/.csv`, unduh template, pemetaan kolom + alias, parsing tanggal/jam, resolusi status.
|
||||
- Tombol **Export Excel**, **Import Excel** di halaman Pelamar dan Pengaturan; **Unduh Template Excel** di Pengaturan.
|
||||
- Import: nomor dinormalisasi, status dicocokkan, baris tidak valid dilewati & dilaporkan.
|
||||
|
||||
Hasil uji round-trip: export 17 kolom benar; import 3 baris (status/ tanggal/jam terpetakan); baris invalid terdeteksi.
|
||||
|
||||
---
|
||||
|
||||
## Turn 10
|
||||
|
||||
**User:**
|
||||
|
||||
> pencarian di pelamar tidak jalan
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Bug ditemukan: `q.replace(/\D/g,'')` menjadi string kosong saat kata kunci tanpa angka, dan `indexOf('')` selalu `0` → semua baris cocok.
|
||||
|
||||
Perbaikan: cek nomor HP hanya jika ada angka; tambah pencarian email; `trim` kata kunci; pesan kosong dibedakan ("Tidak ada pelamar yang cocok..." vs "Belum ada data pelamar...").
|
||||
|
||||
Hasil uji: `budi`→1, `admin`→2, `0813`→1, `zzz`→0, kosong→3.
|
||||
|
||||
---
|
||||
|
||||
## Turn 11
|
||||
|
||||
**User:**
|
||||
|
||||
> oke, semua fungsional sudah oke, untuk UI minta dibuat lebih modern, dengan campuran warna biru dan hijau dan ada logo E
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Merombak UI:
|
||||
- Palet gradien biru `#2563eb` → hijau `#10b981`.
|
||||
- Logo **"E"** gradien + teks "E-recruitment" bergradien + favicon `assets/img/logo.svg`.
|
||||
- Navbar frosted dengan garis gradien; menu aktif pill bergradien.
|
||||
- Latar halaman dengan radial glow biru & hijau.
|
||||
- Kartu ringkasan dengan bar aksen per status + hover terangkat.
|
||||
- Tombol primer & WhatsApp bergradien; tabel sticky header & hover; modal header bergradien; toast bergradien; focus ring; scrollbar rapi; responsif.
|
||||
|
||||
Hasil uji: keempat halaman tanpa error, logo "E" tampil, favicon terpasang, kartu ringkasan pakai aksen gradien.
|
||||
|
||||
---
|
||||
|
||||
## Turn 12
|
||||
|
||||
**User:**
|
||||
|
||||
> tolong export semua conversesion ke file .md
|
||||
|
||||
**Asisten:**
|
||||
|
||||
Membuat file ini: `riwayat-percakapan.md`.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan Keputusan Proyek
|
||||
|
||||
| Topik | Keputusan |
|
||||
|---|---|
|
||||
| Bentuk | Web statis (HTML + CSS + JS), tanpa backend & build tool |
|
||||
| Penyimpanan | localStorage + IndexedDB (berkas) + Export/Import JSON & Excel |
|
||||
| Pengiriman WA | Manual via `wa.me` (user menekan kirim) |
|
||||
| Status | Baru, Screening, Interview, Diterima, Ditolak, Gagal/Tidak Melanjutkan |
|
||||
| Template | Formal dari HR; jenis Umum / Reminder Pelamar / Reminder Pewawancara |
|
||||
| Pengguna | HR saja (single user) |
|
||||
| Nama perusahaan | E-recruitment |
|
||||
| Tema | Modern, gradien biru–hijau, logo "E" |
|
||||
|
||||
## Daftar File Akhir
|
||||
|
||||
```
|
||||
VC-Final/
|
||||
├─ agen.md
|
||||
├─ README.md
|
||||
├─ riwayat-percakapan.md
|
||||
├─ index.html
|
||||
├─ template.html
|
||||
├─ berkas.html
|
||||
├─ pengaturan.html
|
||||
└─ assets/
|
||||
├─ css/style.css
|
||||
├─ img/logo.svg
|
||||
└─ js/
|
||||
├─ store.js
|
||||
├─ phone.js
|
||||
├─ whatsapp.js
|
||||
├─ ui.js
|
||||
├─ excel.js
|
||||
├─ app.js
|
||||
├─ templates.js
|
||||
├─ settings.js
|
||||
├─ berkas.js
|
||||
└─ vendor/xlsx.full.min.js
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Template Pesan — E-recruitment</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/img/logo.svg">
|
||||
<link rel="stylesheet" href="assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="index.html">
|
||||
<span class="logo-mark">E</span>
|
||||
<span class="logo-text">E-recruitment</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="index.html">Pelamar</a>
|
||||
<a href="template.html" class="active">Template Pesan</a>
|
||||
<a href="berkas.html">Berkas</a>
|
||||
<a href="pengaturan.html">Pengaturan</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Template Pesan WhatsApp</h1>
|
||||
<p class="muted">Pesan formal yang dipakai saat menghubungi pelamar. Placeholder akan otomatis diganti data pelamar.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btnAddTpl">+ Tambah Template</button>
|
||||
</div>
|
||||
|
||||
<div id="tplList"></div>
|
||||
<div class="empty hidden" id="tplEmpty">Belum ada template.</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/store.js"></script>
|
||||
<script src="assets/js/phone.js"></script>
|
||||
<script src="assets/js/whatsapp.js"></script>
|
||||
<script src="assets/js/ui.js"></script>
|
||||
<script src="assets/js/templates.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user