feat: inital commit project

This commit is contained in:
2026-09-18 19:30:05 +07:00
commit b2208afcb6
19 changed files with 3519 additions and 0 deletions
+568
View File
@@ -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);
})();
+132
View File
@@ -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);
})();
+212
View File
@@ -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);
+28
View File
@@ -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);
+203
View File
@@ -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);
})();
+533
View File
@@ -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);
+177
View File
@@ -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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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">&times;</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);
File diff suppressed because one or more lines are too long
+81
View File
@@ -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);