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