// ============================================================ // Email Notifications Module // ============================================================ async function renderEmailNotifications(params = {}) { const content = document.getElementById('page-content'); if (!Auth.isAdmin() && !Auth.isDev()) { content.innerHTML = `
πŸ”’
Admin Access Only
`; return; } content.innerHTML = `

Email Notifications

SMTP configuration, notification rules and delivery log

`; await _emailLoadData(); emailTab('settings', document.querySelector('#email-tabs .tab-btn')); } let _emailData = { settings: {}, rules: [], roles: [] }; async function _emailLoadData() { const [res, rolesRes] = await Promise.all([ API.post('email_notifications/index', { action: 'get_settings' }), API.post('roles/list', {}), ]); if (res.success) { _emailData.settings = res.data.settings || {}; _emailData.rules = res.data.rules || []; } _emailData.roles = rolesRes.data?.roles || []; } function emailTab(tab, btn) { document.querySelectorAll('#email-tabs .tab-btn').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); const body = document.getElementById('email-tab-body'); if (!body) return; if (tab === 'settings') renderEmailSettings(body); if (tab === 'rules') renderEmailRules(body); if (tab === 'log') renderEmailLog(body); if (tab === 'queue') renderEmailQueue(body); if (tab === 'tools') renderSystemTools(body); } // ── SMTP Settings Tab ──────────────────────────────────── function renderEmailSettings(wrap) { const s = _emailData.settings; const enabled = s.email_enabled === '1'; wrap.innerHTML = `
πŸ“‘ SMTP Configuration
βœ‰οΈ Sender Identity
🎯 Routing
Receives admin-level notifications for all events
Used to generate "View Job Card" etc. links in emails
⏱️ Cron Job Setup

Add one of these lines to your server's crontab to enable automatic email sending:

# Every minute (real-time delivery)
* * * * * php /home/[user]/public_html/cron/send_notifications.php >> /var/log/ewg_email.log 2>&1
# Every 5 minutes (lower server load)
*/5 * * * * php /home/[user]/public_html/cron/send_notifications.php >> /var/log/ewg_email.log 2>&1
⚠️ Replace /home/[user]/public_html/ with your actual server path. On cPanel you can add crons under Cron Jobs in your hosting panel.
`; } async function emailSaveSettings() { const btn = event.target; btn.classList.add('loading'); const res = await API.post('email_notifications/index', { action: 'save_settings', email_enabled: document.getElementById('email-enabled').checked ? '1' : '0', email_smtp_host: document.getElementById('es-host').value.trim(), email_smtp_port: document.getElementById('es-port').value.trim(), email_smtp_secure: document.getElementById('es-secure').value, email_smtp_user: document.getElementById('es-user').value.trim(), email_smtp_pass: document.getElementById('es-pass').value, email_from_address: document.getElementById('es-from-email').value.trim(), email_from_name: document.getElementById('es-from-name').value.trim(), email_admin_address: document.getElementById('es-admin-email').value.trim(), email_app_url: document.getElementById('es-app-url').value.trim(), }); btn.classList.remove('loading'); if (res.success) { Toast.show('Email settings saved βœ…', 'success'); await _emailLoadData(); } else Toast.show(res.message, 'error'); } async function emailSendTest() { const email = document.getElementById('es-test-email').value.trim(); const result = document.getElementById('email-test-result'); if (!email) { Toast.show('Enter a test email address first', 'warning'); return; } result.innerHTML = `
Sending test email...
`; const res = await API.post('email_notifications/index', { action: 'test_send', to_email: email }); if (res.success) { result.innerHTML = `
βœ… Test email sent successfully to ${_esc(email)}!
`; } else { result.innerHTML = `
❌ ${_esc(res.message)}
${res.errors?.length ? `
SMTP Debug Log
${_esc(res.errors.join('\n'))}
` : ''}`; } } // ── Notification Rules Tab ─────────────────────────────── function renderEmailRules(wrap) { const rules = _emailData.rules; const roles = _emailData.roles; const ruleMap = {}; rules.forEach(r => ruleMap[r.event_type] = r); // ── Section definitions ────────────────────────────────── const SECTIONS = [ { key: 'jobcards', label: 'πŸ“‹ Job Cards', desc: 'Notifications triggered by job card activity', events: [ { type: 'job_created', label: 'Job Card Created', desc: 'When a new job card is created' }, { type: 'job_assigned', label: 'Job Card Assigned', desc: 'When a job card is assigned or reassigned' }, { type: 'job_status_changed', label: 'Status Changed', desc: 'When a job card status changes' }, { type: 'job_completed', label: 'Job Card Completed', desc: 'When marked completed or internal complete' }, { type: 'job_invoiced', label: 'Job Card Invoiced', desc: 'When a job card is marked as invoiced' }, ], hasAssignedPerson: true, hasClient: true, }, { key: 'checklists', label: 'βœ… Checklists', desc: 'Notifications for checklist compliance and reminders', events: [ { type: 'checklist_due', label: 'Checklist Due Today', desc: 'Daily reminder β€” sent via cron each morning' }, { type: 'checklist_missed', label: 'Checklist Overdue', desc: 'When a checklist was not completed by its due date' }, { type: 'checklist_completed', label: 'Checklist Completed', desc: 'When a checklist is submitted' }, ], hasAssignedPerson: true, hasClient: false, }, { key: 'stock', label: 'πŸ“¦ Stock', desc: 'Inventory alerts', events: [ { type: 'low_stock', label: 'Low Stock Alert', desc: 'When an item drops below its minimum quantity after a book-out' }, ], hasAssignedPerson: false, hasClient: false, }, { key: 'hr', label: 'πŸ‘₯ HR & Leave', desc: 'Employee leave and HR notifications', events: [ { type: 'leave_submitted', label: 'Leave Request Submitted', desc: 'When an employee submits a leave request' }, { type: 'leave_actioned', label: 'Leave Approved / Rejected', desc: 'When leave is approved or rejected by HR' }, ], hasAssignedPerson: false, hasClient: false, }, ]; function roleCheckboxes(eventType, activeRoleIds) { if (!roles.length) return 'No roles defined'; return `
${roles.map(role => ` `).join('')}
`; } function renderSection(section) { const rows = section.events.map(ev => { const r = ruleMap[ev.type]; if (!r) return ''; const activeRoleIds = (r.notify_role_ids || '').split(',').map(x => x.trim()).filter(Boolean); const assignedCell = section.hasAssignedPerson ? ` ` : ` β€” `; const clientCell = section.hasClient ? ` ` : ` β€” `; return `
${ev.label}
${ev.desc}
${roleCheckboxes(ev.type, activeRoleIds)} ${assignedCell} ${clientCell} `; }).join(''); return `
${section.label}
${section.desc}
${rows || ''}
Event Active Notify Roles ${section.hasAssignedPerson ? 'Assigned
Person
' : 'β€”'}
${section.hasClient ? 'Client' : 'β€”'} Extra Recipients
No rules configured for this section.
`; } wrap.innerHTML = `
Notify Roles β€” emails every active user account with that role  Β·  Assigned Person β€” the specific person assigned to that job / checklist  Β·  Client β€” the client contact email on the job card  Β·  Extra Recipients β€” any additional addresses, comma-separated
${SECTIONS.map(renderSection).join('')}
`; } async function emailSaveRules() { const btns = document.querySelectorAll('[onclick="emailSaveRules()"]'); btns.forEach(b => b.classList.add('loading')); const rules = []; document.querySelectorAll('tr[id^="rule-row-"]').forEach(row => { const eventType = row.id.replace('rule-row-', ''); const rule = { event_type: eventType }; // Standard checkbox / text fields row.querySelectorAll('.rule-field').forEach(el => { rule[el.dataset.field] = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value; }); // Role checkboxes β†’ notify_role_ids const checkedRoles = [...row.querySelectorAll('.rule-role-cb:checked')].map(cb => cb.dataset.roleId); rule.notify_role_ids = checkedRoles.join(','); rules.push(rule); }); const res = await API.post('email_notifications/index', { action: 'save_rules', rules }); btns.forEach(b => b.classList.remove('loading')); if (res.success) { Toast.show('Notification rules saved βœ…', 'success'); await _emailLoadData(); } else { Toast.show(res.message, 'error'); } } // ── Delivery Log Tab ──────────────────────────────────── async function renderEmailLog(wrap) { wrap.innerHTML = `
`; const res = await API.post('email_notifications/index', { action: 'get_log', limit: 50 }); if (!res.success) { wrap.innerHTML = `
${res.message}
`; return; } const logs = res.data.logs || []; wrap.innerHTML = `
πŸ“œ Delivery Log ${logs.length} recent
${logs.length ? `
${logs.map(l => ` `).join('')}
TimeTypeRecipientSubjectStatusError
${_fmtDt(l.sent_at)} ${l.notification_type || 'β€”'} ${_esc(l.recipient_email)} ${_esc(l.subject)} ${l.status === 'sent' ? 'βœ“ Sent' : 'βœ— Failed' } ${l.error_message ? _esc(l.error_message.substring(0, 80)) : 'β€”'}
` : `
πŸ“¬
No emails sent yet

Sent emails will appear here once the cron runs.

`}
`; } async function emailClearLog() { if (!await Modal.confirm('Delete log records older than 30 days?', 'Clear Log', false)) return; const res = await API.post('email_notifications/index', { action: 'clear_old', days: 30 }); if (res.success) { Toast.show('Old records cleared', 'success'); emailTab('log', null); } else Toast.show(res.message, 'error'); } // ── Queue Tab ──────────────────────────────────────────── async function renderEmailQueue(wrap) { wrap.innerHTML = `
`; const [pendingRes, failedRes, statsRes] = await Promise.all([ API.post('email_notifications/index', { action: 'get_queue', status: 'pending', limit: 50 }), API.post('email_notifications/index', { action: 'get_queue', status: 'failed', limit: 50 }), API.post('email_notifications/index', { action: 'get_stats' }), ]); const pending = pendingRes.data?.queue || []; const failed = failedRes.data?.queue || []; const stats = statsRes.data || {}; const qMap = {}; (stats.queue || []).forEach(s => qMap[s.status] = s.cnt); const logMap = {}; (stats.log_30d || []).forEach(s => logMap[s.status] = s.cnt); const lastSent = stats.last_sent ? _fmtDt(stats.last_sent) : 'Never'; const allItems = [...pending, ...failed]; wrap.innerHTML = `
${_statCard('πŸ“€ Pending', qMap.pending || 0, '#fef9c3', '#713f12')} ${_statCard('βœ“ Sent (30d)', logMap.sent || 0, '#dcfce7', '#166534')} ${_statCard('βœ— Failed', qMap.failed || 0, '#fee2e2', '#991b1b')} ${_statCard('πŸ• Last Sent', lastSent, '#f0f9ff', '#0c4a6e')}
πŸ“€ Email Queue
${failed.length ? `` : ''} ${allItems.length ? ` ` : ''}
${allItems.length ? `
${allItems.map(q => ` `).join('')}
StatusTypeRecipientSubjectAttemptsCreatedError
${q.status === 'pending' ? 'Pending' : 'Failed' } ${q.notification_type} ${_esc(q.recipient_email)} ${_esc(q.subject)} ${q.attempts}/3 ${_fmtDt(q.created_at)} ${q.error_message ? _esc(q.error_message.substring(0, 60)) : 'β€”'}
` : `
βœ…
Queue is clear

All emails have been delivered.

`}
`; } function emailQueueToggleAll(checked) { document.querySelectorAll('.eq-row-cb').forEach(cb => cb.checked = checked); emailQueueSelectionChanged(); } function emailQueueSelectionChanged() { const anyChecked = [...document.querySelectorAll('.eq-row-cb')].some(cb => cb.checked); const btn = document.getElementById('eq-delete-sel-btn'); if (btn) btn.style.display = anyChecked ? '' : 'none'; } async function emailDeleteSelected() { const ids = [...document.querySelectorAll('.eq-row-cb:checked')].map(cb => parseInt(cb.dataset.id)); if (!ids.length) return; if (!await Modal.confirm(`Delete ${ids.length} selected email(s) from the queue? This cannot be undone.`, 'Delete Emails', true)) return; await emailDeleteQueued(ids); } async function emailDeleteQueued(ids) { const res = await API.post('email_notifications/index', { action: 'delete_queued', ids }); if (res.success) { Toast.show(res.message, 'success'); renderEmailQueue(document.getElementById('email-tab-body')); } else { Toast.show(res.message, 'error'); } } async function emailClearQueue() { if (!await Modal.confirm('Remove ALL emails from the queue (pending and failed)? This cannot be undone.', 'Clear Queue', true)) return; const res = await API.post('email_notifications/index', { action: 'clear_queue' }); if (res.success) { Toast.show(`Queue cleared β€” ${res.data.deleted} email(s) removed.`, 'success'); renderEmailQueue(document.getElementById('email-tab-body')); } else { Toast.show(res.message, 'error'); } } async function emailRetryFailed() { const res = await API.post('email_notifications/index', { action: 'retry_failed' }); if (res.success) { Toast.show('Failed emails reset β€” will retry on next cron run', 'success'); renderEmailQueue(document.getElementById('email-tab-body')); } else Toast.show(res.message, 'error'); } // ── System Tools Tab ───────────────────────────────────── function renderSystemTools(wrap) { const CRONS = [ { id: 'send_notifications', icon: 'πŸ“§', label: 'Send Notifications', desc: 'Processes the email queue and sends pending emails. Also queues checklist due/missed alerts for today. Runs every minute in production.', warning: null, force: false, }, { id: 'generate_checklists', icon: 'βœ…', label: 'Generate Checklists', desc: 'Creates upcoming checklist instances based on templates (daily, weekly, monthly). Marks overdue instances as missed. Runs daily at 02:00.', warning: null, force: false, }, { id: 'allocate_leave', icon: '🌴', label: 'Allocate Leave', desc: 'Allocates monthly leave balances to all active employees. Normally only runs on the 1st of each month.', warning: 'This job is date-guarded to run on the 1st of the month. Use Force Run to bypass for testing.', force: true, }, ]; wrap.innerHTML = `
βš™οΈ Cron Jobs
Manually trigger background jobs for testing. Output is shown in real time below each job.
${CRONS.map(c => `
${c.icon}
${c.label}
${c.desc}
${c.warning ? `
${c.warning}
` : ''}
${c.force ? ` ` : ''}
`).join('')}
πŸ“‹ Cron Schedule Reference
# Every minute β€” email queue processor
* * * * * php /home/[user]/public_html/cron/send_notifications.php

# Daily at 02:00 β€” checklist generator + missed marker
0 2 * * * php /home/[user]/public_html/cron/generate_checklists.php

# Daily at 01:00 β€” leave allocation (self-guards to 1st of month)
0 1 * * * php /home/[user]/public_html/cron/allocate_leave.php
Replace /home/[user]/public_html/ with your actual server path. Add via cPanel β†’ Cron Jobs.
`; } async function runCron(cronId, force = false) { const btn = document.getElementById(force ? `cron-force-btn-${cronId}` : `cron-btn-${cronId}`); const outputEl = document.getElementById(`cron-output-${cronId}`); const allBtns = document.querySelectorAll(`#cron-btn-${cronId}, #cron-force-btn-${cronId}`); allBtns.forEach(b => b.classList.add('loading')); outputEl.style.display = 'block'; outputEl.textContent = '⏳ Running…'; const started = Date.now(); const res = await API.post('admin/run_cron', { action: 'run', cron: cronId, force: force ? '1' : '0' }); const elapsed = ((Date.now() - started) / 1000).toFixed(1); allBtns.forEach(b => b.classList.remove('loading')); if (res.success) { const exitOk = res.data?.exit_code === 0; const lines = (res.data?.output || '').split('\n'); const colored = lines.map(line => { if (/βœ“|sent|success|queued|generated|allocated/i.test(line)) return `\x1b[0m${_esc(line)}`; if (/βœ—|fail|error|fatal|warn/i.test(line)) return `${_esc(line)}`; if (/skip|not 1st|disabled/i.test(line)) return `${_esc(line)}`; return `${_esc(line)}`; }).join('\n'); outputEl.innerHTML = `${exitOk ? 'βœ“ Completed' : 'βœ— Finished with errors'} in ${res.data?.elapsed ?? elapsed}s (exit: ${res.data?.exit_code})\n\n` + colored; outputEl.scrollTop = outputEl.scrollHeight; Toast.show(`${cronId} β€” ${exitOk ? 'completed βœ…' : 'finished with errors ⚠'}`, exitOk ? 'success' : 'warning'); } else { outputEl.innerHTML = `❌ ${_esc(res.message)}`; Toast.show(res.message, 'error'); } } function _esc(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function _fmtDt(dt) { if (!dt) return 'β€”'; const d = new Date(dt.replace(' ', 'T')); return d.toLocaleString('en-ZA', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); } function _statCard(label, value, bg, color) { return `
${label}
${value}
`; }