Files
E-Recruitment/assets/js/store.js
T
2026-09-18 19:30:05 +07:00

534 lines
20 KiB
JavaScript

/* 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);