registerPage('bookings', async (content, params = {}) => { document.getElementById('topbar-title').textContent = 'Bookings'; // ── List ─────────────────────────────────────────────────────────────────── async function render(statusFilter = '') { const r = await api('bookings.php', { action: 'list', status: statusFilter }); if (!r.success) { document.getElementById('bookings-table').innerHTML = emptyHTML('Failed: ' + r.error); return; } const rows = r.bookings; document.getElementById('bookings-table').innerHTML = rows.length === 0 ? emptyHTML('No bookings found') : `
${rows.map(b => ``).join('')}
#DateClientAssessorStatusActions
${b.booking_number || b.record_id} ${b.date_booked} ${b.clients_name || '—'} ${b.assessor_name || '—'} ${statusBadge(b.status)} ${b.status === 'DRAFT' || b.status === 'NOT APPROVED' ? `` : ''} ${b.status === 'DRAFT' || b.status === 'NOT APPROVED' ? `` : ''}
`; } content.innerHTML = `
${loadingHTML()}
`; document.getElementById('booking-status-filter').addEventListener('change', e => render(e.target.value)); render(params.status || ''); // ── Shared form state ────────────────────────────────────────────────────── let formEmployees = []; let allAssessments = []; let allTests = []; let allSites = []; let currentClientId = null; let editingBookingId = null; // ── Employee table renderer ──────────────────────────────────────────────── // Shared fixed-position dropdown element — one instance reused for all rich selects let _rsDropdown = null; let _rsActiveInput = null; let _rsOnChange = null; function getRsDropdown() { if (!_rsDropdown) { _rsDropdown = document.createElement('div'); _rsDropdown.id = 'rs-global-dropdown'; _rsDropdown.style.cssText = 'position:fixed;z-index:9999;background:var(--card);border:1px solid var(--border);' + 'border-radius:7px;max-height:200px;overflow-y:auto;box-shadow:0 8px 24px rgba(0,0,0,.18);display:none;min-width:200px'; document.body.appendChild(_rsDropdown); document.addEventListener('mousedown', ev => { if (_rsDropdown && !_rsDropdown.contains(ev.target) && ev.target !== _rsActiveInput) { _rsDropdown.style.display = 'none'; } }); } return _rsDropdown; } function positionRsDropdown(input) { const dd = getRsDropdown(); const rect = input.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom; const spaceAbove = rect.top; const dropH = Math.min(200, dd.scrollHeight || 200); if (spaceBelow >= dropH || spaceBelow >= spaceAbove) { dd.style.top = (rect.bottom + 2) + 'px'; dd.style.bottom = 'auto'; } else { dd.style.bottom = (window.innerHeight - rect.top + 2) + 'px'; dd.style.top = 'auto'; } dd.style.left = rect.left + 'px'; dd.style.width = rect.width + 'px'; } function makeRichSelect(wrap, combined, currentVal, currentLabel, onChange) { wrap.innerHTML = `
`; const input = wrap.querySelector('.rs-input'); const hidden = wrap.querySelector('.rs-value'); function showDropdown(filterQ) { const dd = getRsDropdown(); _rsActiveInput = input; _rsOnChange = onChange; dd.innerHTML = combined .filter(it => it.label.toLowerCase().includes(filterQ.toLowerCase())) .map(it => `
${it.label}
`).join('') || '
No results
'; dd.querySelectorAll('.rs-fixed-option').forEach(opt => { opt.addEventListener('mousedown', ev => { ev.preventDefault(); input.value = opt.dataset.label; hidden.value = opt.dataset.id; dd.style.display = 'none'; _rsOnChange && _rsOnChange(opt.dataset.id, opt.dataset.label); }); }); positionRsDropdown(input); dd.style.display = 'block'; } input.addEventListener('focus', () => showDropdown(input.value)); input.addEventListener('input', () => { hidden.value = ''; showDropdown(input.value); }); input.addEventListener('blur', () => { setTimeout(() => { if (document.getElementById('rs-global-dropdown')) getRsDropdown().style.display = 'none'; }, 150); }); } function renderEmployeeTable() { const tbody = document.getElementById('emp-booking-list'); if (!tbody) return; if (formEmployees.length === 0) { tbody.innerHTML = `No employees added yet`; return; } tbody.innerHTML = formEmployees.map((e, i) => ` ${e.name} ${e.surname} ${e.id_doc || '—'}
`).join(''); const combined = [ ...allAssessments.map(a => ({ id: 'a_' + a.record_id, label: '📋 ' + a.assessments_name })), ...allTests.map(t => ({ id: 't_' + t.record_id, label: '📝 ' + t.test_name })), ]; const siteOptions = allSites.map(s => ({ id: String(s.record_id), label: s.client_sites_name })); formEmployees.forEach((e, i) => { // Assessment/Test rich select const assLabel = e.assessment_id ? '📋 ' + (allAssessments.find(a => a.record_id == e.assessment_id)?.assessments_name || e.assessment_id) : e.test_id ? '📝 ' + (allTests.find(t => t.record_id == e.test_id)?.test_name || e.test_id) : ''; const assVal = e.assessment_id ? 'a_' + e.assessment_id : e.test_id ? 't_' + e.test_id : ''; makeRichSelect(document.getElementById(`rs-ass-${i}`), combined, assVal, assLabel, (val) => { if (val.startsWith('a_')) { formEmployees[i].assessment_id = val.replace('a_', ''); formEmployees[i].test_id = null; } else { formEmployees[i].test_id = val.replace('t_', ''); formEmployees[i].assessment_id = null; } }); // Site rich select const siteLabel = e.site_id ? (allSites.find(s => s.record_id == e.site_id)?.client_sites_name || e.site_id) : ''; makeRichSelect(document.getElementById(`rs-site-${i}`), siteOptions, e.site_id || '', siteLabel, (val) => { formEmployees[i].site_id = val; }); }); } window.removeEmployee = (i) => { formEmployees.splice(i, 1); renderEmployeeTable(); }; function addEmployeeToForm(emp) { formEmployees.push({ record_id: emp.record_id, name: emp.client_employees_name, surname: emp.surname, id_doc: emp.i_doc_passport, assessment_id: null, test_id: null, site_id: null }); renderEmployeeTable(); } // ── Build the form HTML ──────────────────────────────────────────────────── async function buildForm(booking = null) { const [ar, tr, ur, cr] = await Promise.all([ api('assessments.php', { action: 'list' }), api('tests.php', { action: 'list' }), api('users.php', { action: 'list' }), api('clients.php', { action: 'list' }), ]); allAssessments = ar.assessments || []; allTests = tr.tests || []; const assessors = (ur.users || []).filter(u => [1, 2].includes(+u.user_type_id)); const clients = cr.clients || []; // Load sites if we have a client (editing existing booking) allSites = []; if (booking?.clients_id) { const sr = await api('sites.php', { action: 'list', clients_id: booking.clients_id }); allSites = sr.sites || []; } // Also use sites returned by the get action if available if (booking?.client_sites_list) allSites = booking.client_sites_list; // Pre-fill if editing const bDate = booking?.date_booked || new Date().toISOString().slice(0, 16); const bClient = booking?.clients_id || ''; const bAssessor = booking?.safesure_users_id || ''; const bStatus = booking?.status || 'DRAFT'; const bNotes = booking?.notes || ''; currentClientId = bClient || null; document.getElementById('booking-form-body').innerHTML = `

Employees

Name Surname ID/Passport Assessment / Test Site
`; // Pre-populate employees from positional CSV arrays + keyed maps if (booking && booking.emp_parts) { const empParts = booking.emp_parts; const assParts = booking.ass_parts || []; const testParts = booking.test_parts || []; const siteParts = booking.site_parts || []; const empMap = booking.employees_map || {}; formEmployees = []; empParts.forEach((raw, i) => { const eid = raw.trim(); if (!eid) return; const emp = empMap[eid]; if (!emp) return; // ID not found in DB const assId = (assParts[i] || '').trim(); const testId = (testParts[i] || '').trim(); const siteId = (siteParts[i] || '').trim(); formEmployees.push({ record_id: String(emp.record_id), name: emp.client_employees_name, surname: emp.surname, id_doc: emp.i_doc_passport, assessment_id: assId || null, test_id: testId || null, site_id: siteId || null, }); }); } renderEmployeeTable(); } // ── Employee search ──────────────────────────────────────────────────────── let empSearchTimer = null; window.empSearchInput = async (q) => { const results = document.getElementById('emp-search-results'); if (!currentClientId) { toast('Select a client first', 'error'); return; } clearTimeout(empSearchTimer); if (q.length < 2) { results.style.display = 'none'; return; } empSearchTimer = setTimeout(async () => { const r = await api('employees.php', { action: 'list', clients_id: currentClientId, search: q }); if (!r.success || !r.employees?.length) { results.innerHTML = `
No results
`; results.style.display = 'block'; return; } results.innerHTML = r.employees.map(e => `
${e.client_employees_name} ${e.surname} ${e.i_doc_passport || ''} + add
`).join(''); results.style.display = 'block'; }, 300); }; window.addEmpFromSearch = (emp) => { addEmployeeToForm(emp); document.getElementById('bf-emp-search').value = ''; document.getElementById('emp-search-results').style.display = 'none'; }; document.addEventListener('click', e => { const res = document.getElementById('emp-search-results'); if (res && !res.contains(e.target) && e.target.id !== 'bf-emp-search') res.style.display = 'none'; }); window.bClientChanged = async () => { currentClientId = document.getElementById('bf-client').value || null; document.getElementById('bf-emp-search').value = ''; const res = document.getElementById('emp-search-results'); if (res) res.style.display = 'none'; // Reload sites for newly selected client allSites = []; if (currentClientId) { const sr = await api('sites.php', { action: 'list', clients_id: currentClientId }); allSites = sr.sites || []; } // Re-render employee table to update site dropdowns if (formEmployees.length) renderEmployeeTable(); }; // ── Manual employee ──────────────────────────────────────────────────────── window.openManualEmp = () => { if (!currentClientId) { toast('Select a client first', 'error'); return; } ['me-name', 'me-surname', 'me-id', 'me-occupation'].forEach(id => document.getElementById(id).value = ''); openModal('manual-emp-modal'); }; window.manualEmpSave = async () => { const name = document.getElementById('me-name').value.trim(); const sur = document.getElementById('me-surname').value.trim(); if (!name || !sur) { toast('Name and surname required', 'error'); return; } const r = await api('employees.php', { action: 'create', client_employees_name: name, surname: sur, clients_id: currentClientId, i_doc_passport: document.getElementById('me-id').value.trim(), occupation: document.getElementById('me-occupation').value.trim(), }, 'POST'); if (!r.success) { // 409 = duplicate ID — offer to add existing employee instead if (r.error && r.error.includes('already exists')) { toast('⚠ ' + r.error, 'error'); } else { toast(r.error, 'error'); } return; } addEmployeeToForm({ record_id: r.id, client_employees_name: name, surname: sur, i_doc_passport: document.getElementById('me-id').value.trim() }); closeModal('manual-emp-modal'); toast('Employee created and added', 'success'); }; // ── New booking ──────────────────────────────────────────────────────────── window.bookingAdd = async () => { editingBookingId = null; formEmployees = []; document.getElementById('booking-form-title').textContent = 'New Booking'; document.getElementById('booking-form-body').innerHTML = loadingHTML(); openModal('booking-form-modal'); await buildForm(); }; // ── Edit booking ─────────────────────────────────────────────────────────── window.bookingEdit = async (id) => { editingBookingId = id; formEmployees = []; document.getElementById('booking-form-title').textContent = 'Edit Booking'; document.getElementById('booking-form-body').innerHTML = loadingHTML(); openModal('booking-form-modal'); const r = await api('bookings.php', { action: 'get', id }); if (!r.success) { toast('Failed to load booking', 'error'); closeModal('booking-form-modal'); return; } await buildForm(r.booking); }; // ── Save ─────────────────────────────────────────────────────────────────── window.bookingSave = async () => { const clientId = document.getElementById('bf-client').value; const assessorId = document.getElementById('bf-assessor').value; const date = document.getElementById('bf-date').value; const status = document.getElementById('bf-status').value; const notes = document.getElementById('bf-notes').value; if (!clientId) { toast('Select a client', 'error'); return; } if (!date) { toast('Set a booking date', 'error'); return; } if (!formEmployees.length) { toast('Add at least one employee', 'error'); return; } const empIds = ',' + formEmployees.map(e => e.record_id).join(',') + ','; const assIds = ',' + formEmployees.map(e => e.assessment_id || '').join(',') + ','; const testIds = ',' + formEmployees.map(e => e.test_id || '').join(',') + ','; const siteIds = ',' + formEmployees.map(e => e.site_id || '').join(',') + ','; const params = { action: editingBookingId ? 'update' : 'create', id: editingBookingId || '', date_booked: date, clients_id: clientId, safesure_users_id: assessorId, status, notes, client_employees: empIds, assessments: assIds, tests: testIds, client_sites: siteIds, }; const r = await api('bookings.php', params, 'POST'); if (r.success) { toast(editingBookingId ? 'Booking updated' : 'Booking saved', 'success'); closeModal('booking-form-modal'); render(); } else toast(r.error, 'error'); }; // ── Approve / Delete ─────────────────────────────────────────────────────── window.bookingApprove = async (id) => { if (!confirm('Approve this booking?')) return; const r = await api('bookings.php', { action: 'approve', id }, 'POST'); if (r.success) { toast('Approved', 'success'); closeAllModals(); render(); } else toast(r.error, 'error'); }; window.bookingDelete = async (id) => { if (!confirm('Delete this booking?')) return; const r = await api('bookings.php', { action: 'delete', id }, 'POST'); if (r.success) { toast('Deleted', 'success'); closeAllModals(); render(); } else toast(r.error, 'error'); }; // ── PDF Preview ──────────────────────────────────────────────────────────── window.bookingPDF = async (id) => { const r = await api('bookings.php', { action: 'get', id }); if (!r.success) { toast('Failed to load booking', 'error'); return; } const b = r.booking; const empParts = (b.client_employees || '').split(','); const assParts = (b.assessments || '').split(','); const testParts = (b.tests || '').split(','); const empRows = empParts.map((raw, i) => { const eid = raw.trim(); if (!eid) return ''; const emp = (b.employees || []).find(e => e.record_id == eid); if (!emp) return ''; const assId = (assParts[i] || '').trim(); const testId = (testParts[i] || '').trim(); const assName = assId ? (b.assessment_details || []).find(a => a.record_id == assId)?.assessments_name : ''; const testName = testId ? (b.test_details || []).find(t => t.record_id == testId)?.test_name : ''; const typeLabel = assName ? 'Assessment' : testName ? 'Test' : ''; const typeName = assName || testName || '—'; return ` ${i + 1} ${emp.client_employees_name} ${emp.surname} ${emp.i_doc_passport || '—'} ${emp.occupation || '—'} ${typeLabel} ${typeName} `; }).filter(Boolean).join(''); const html = ` Booking #${b.booking_number || b.record_id}
SS

SafeSure

Competency Assessment

Booking Sheet

#${b.booking_number || b.record_id}
Printed: ${new Date().toLocaleDateString('en-ZA', { day: 'numeric', month: 'short', year: 'numeric' })}

${b.clients_name || '—'}

${b.assessor_full_name || b.assessor_name || '—'}

${b.status || '—'}

${b.date_booked || '—'}

${b.date_approved || '—'}

${empParts.filter(v => v.trim()).length}

${b.notes ? `

Notes

${b.notes}

` : ''}

Employee Register

${empRows || ''}
#Full NameID / PassportOccupationTypeAssessment / Test
No employees

Assessor Signature & Date

Client Representative Signature & Date