feat: inital commit project
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user