// ============================================================ // Employees Module โ€” Expanded // ============================================================ let empState = { page: 1, search: '', status: '' }; async function renderEmployees(params = {}) { if (!Auth.isHR() && !Auth.isAdmin()) { return renderMyProfile(); } if (params.id) return renderEmployeeDetail(params.id); if (params.tab === 'leave') return renderLeaveAdmin(); if (params.tab === 'settings') return renderHRSettings(); const content = document.getElementById('page-content'); content.innerHTML = `

Employees

Team members, HR records & documents

${Auth.can('employees', 'create') ? `` : ''}
`; await loadEmployees(1); } const empSearchDebounced = debounce(val => { empState.search = val; loadEmployees(1); }, 350); async function loadEmployees(page = 1) { empState.page = page; const wrap = document.getElementById('emp-table-wrap'); if (!wrap) return; const res = await API.post('employees/list', { page, limit: 20, search: empState.search, status: empState.status }); if (!res.success) { wrap.innerHTML = `
${res.message}
`; return; } const emps = res.data.employees; if (!emps.length) { wrap.innerHTML = `
๐Ÿ‘ฅ
No employees found
`; document.getElementById('emp-pagination').innerHTML = ''; return; } if (window.innerWidth < 768) { wrap.innerHTML = `
${emps.map(e => `
${avatarHTML(e.first_name + ' ' + e.last_name, 'lg')}
${e.first_name} ${e.last_name}
${e.job_title || e.department || 'โ€”'}
${e.work_email || e.personal_email || ''}
${Fmt.statusBadge(e.status)} ${e.current_salary ? 'R ' + parseFloat(e.current_salary).toFixed(2) : 'No salary'}
`).join('')}
`; } else { wrap.innerHTML = `
${emps.map(e => ` `).join('')}
EmployeeEmp No.DepartmentJob TitleTypeSalaryUserStatus
${avatarHTML(e.first_name + ' ' + e.last_name, 'lg')}
${e.first_name} ${e.last_name}
${e.work_email || e.personal_email || 'โ€”'}
${e.employee_number} ${e.department || 'โ€”'} ${e.job_title || 'โ€”'} ${Fmt.capitalize(e.employment_type || '')} ${e.current_salary ? 'R ' + parseFloat(e.current_salary).toFixed(2) : 'โ€”'} ${e.linked_username ? `${e.linked_username}` : 'None'} ${Fmt.statusBadge(e.status)}
`; } renderPagination('emp-pagination', res.data.pagination, `p => loadEmployees(p)`); } // โ”€โ”€ Employee Detail โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function renderEmployeeDetail(id) { const content = document.getElementById('page-content'); content.innerHTML = `
`; const res = await API.post('employees/get', { id }); if (!res.success) { Toast.show(res.message, 'error'); return; } const e = res.data.employee; const currentSalary = e.salaries?.[0]?.basic_salary; const activeWarnings = (e.warnings || []).filter(w => w.is_active).length; const leaveBalMap = {}; (e.leave_balances || []).forEach(b => { leaveBalMap[b.leave_type] = b; }); content.innerHTML = `
${Fmt.initials(e.first_name + ' ' + e.last_name)}

${e.first_name} ${e.last_name}

${e.employee_number} ยท ${e.job_title || 'No title'} ${e.department ? 'ยท ' + e.department : ''}
${Fmt.statusBadge(e.status)} ${activeWarnings ? `โš  ${activeWarnings} Warning(s)` : ''} ${Auth.can('employees', 'edit') ? `` : ''}

${e.first_name} ${e.last_name}

${e.id_number || 'โ€”'}

${Fmt.date(e.date_of_birth) || 'โ€”'}

${Fmt.capitalize(e.gender || 'โ€”')}

${e.nationality || 'โ€”'}

${Fmt.capitalize(e.marital_status || 'โ€”')}

${e.personal_email || 'โ€”'}

${e.phone || 'โ€”'}

Emergency Contact

${e.emergency_contact_name || 'โ€”'}

${e.emergency_contact_phone || 'โ€”'}

Address

${[e.address_line1, e.address_line2, e.city, e.province, e.postal_code].filter(Boolean).join(', ') || 'โ€”'}

${e.job_title || 'โ€”'}

${e.department || 'โ€”'}

${Fmt.capitalize(e.employment_type || 'โ€”')}

${Fmt.statusBadge(e.status)}

${Fmt.date(e.start_date)}

${Fmt.date(e.probation_end_date) || 'โ€”'}

${e.work_email || 'โ€”'}

${e.tax_number || 'โ€”'}

${e.uif_number || 'โ€”'}

${e.days_per_month || '21.67'}

${e.hours_per_day || '8'}

Linked System User
${e.linked_user_id ? `
${avatarHTML(e.linked_user_name, 'lg')}
${e.linked_user_name}
${e.linked_username}
` : '

No system user linked. Link a user so they can view their profile and apply for leave.

'}
Salary History
${Auth.can('employees', 'edit') ? `` : ''}
${e.salaries?.length ? `
${e.salaries.map(s => { const dpm = parseFloat(e.days_per_month || 21.67), hpd = parseFloat(e.hours_per_day || 8); const hr = s.salary_type === 'hourly' ? parseFloat(s.basic_salary) : s.salary_type === 'monthly' ? parseFloat(s.basic_salary) / (dpm * hpd) : parseFloat(s.basic_salary) / hpd; return ``; }).join('')}
Effective DateTypeBasic SalaryHourly Rate*
${Fmt.date(s.effective_date)}${Fmt.capitalize(s.salary_type)}${Fmt.currency(s.basic_salary)}R ${hr.toFixed(2)}/hr

* Rate = Salary รท (${e.days_per_month || 21.67} days ร— ${e.hours_per_day || 8} hrs/day)

` : `
No salary records
`} ${e.allowances?.length ? `
Allowances
${e.allowances.map(a => ``).join('')}
TypeAmountTaxable
${a.type}${Fmt.currency(a.amount)}${a.is_taxable ? 'Yes' : 'No'}
` : ''} ${e.deductions?.length ? `
Deductions
${e.deductions.map(d => ``).join('')}
TypeAmount
${d.type}${d.is_percentage ? d.amount + '%' : Fmt.currency(d.amount)}
` : ''}
Leave
${Auth.can('employees', 'create') ? `` : ''}
Documents
${Auth.can('employees', 'create') ? `` : ''}
Disciplinary / Warnings
${Auth.can('employees', 'edit') ? `` : ''}
๐Ÿ”’ Sensitive โ€” Banking details require confidential handling.

${e.bank_name || 'โ€”'}

${Fmt.capitalize(e.account_type || 'โ€”')}

${e.bank_account_no || 'โ€”'}

${e.bank_branch_code || 'โ€”'}

Current Salary
${currentSalary ? Fmt.currency(currentSalary) : 'โ€”'}

${e.salaries?.[0] ? Fmt.capitalize(e.salaries[0].salary_type) + ' ยท Eff. ' + Fmt.date(e.salaries[0].effective_date) : 'No record'}

${Auth.can('employees', 'edit') ? `` : ''}
Leave Balance (${new Date().getFullYear()})
${['annual', 'sick', 'family'].map(t => { const b = leaveBalMap[t]; const alloc = b ? parseFloat(b.allocated) + parseFloat(b.carried_over) : 0; const used = b ? parseFloat(b.used) : 0; const avail = Math.max(0, alloc - used); const pct = alloc > 0 ? Math.min(100, Math.round((used / alloc) * 100)) : 0; return `
${Fmt.capitalize(t)} Leave ${avail.toFixed(1)} / ${alloc.toFixed(1)} days
`; }).join('')}
`; } // โ”€โ”€ Leave tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function loadEmpLeaveDetail(empId) { const wrap = document.getElementById('emp-leave-detail'); if (!wrap) return; const [balRes, leaveRes] = await Promise.all([ API.post('leave/balance', { employee_id: empId }), API.post('leave/list', { employee_id: empId, year: new Date().getFullYear(), limit: 50, page: 1 }) ]); const balances = balRes.data?.balances || []; const leaves = leaveRes.data?.leaves || []; const bm = {}; balances.forEach(b => { bm[b.leave_type] = b; }); wrap.innerHTML = `
${['annual', 'sick', 'family'].map(t => { const b = bm[t]; const alloc = b ? parseFloat(b.allocated) + parseFloat(b.carried_over) : 0; const used = b ? parseFloat(b.used) : 0; const avail = Math.max(0, alloc - used); return `
${Fmt.capitalize(t)} Leave ${avail.toFixed(1)} avail ยท ${used.toFixed(1)} used ยท ${alloc.toFixed(1)} alloc
`; }).join('')}
${leaves.length ? `
${leaves.map(l => ` `).join('')}
TypeStartEndDaysReasonStatus
${Fmt.capitalize(l.leave_type)} ${Fmt.date(l.start_date)}${Fmt.date(l.end_date)} ${l.days} ${l.reason || 'โ€”'} ${Fmt.statusBadge(l.status)} ${l.status === 'pending' && (Auth.isAdmin() || Auth.isHR()) ? `
`: ''}
` : `
No leave records
`}`; } async function leaveAction(id, action, empId) { let reason = ''; if (action === 'reject') reason = prompt('Reason for rejection?') || ''; const res = await API.post('leave/action', { id, action, reason }); if (res.success) { Toast.show(action === 'approve' ? 'Leave approved.' : 'Leave rejected.', 'success'); loadEmpLeaveDetail(empId); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Documents tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function loadEmpDocuments(empId) { const wrap = document.getElementById('emp-docs-list'); if (!wrap) return; const res = await API.post('employees/documents', { action: 'list', employee_id: empId }); const docs = res.data?.documents || []; if (!docs.length) { wrap.innerHTML = `
๐Ÿ“„
No documents uploaded
`; return; } wrap.innerHTML = `
${docs.map(d => ` `).join('')}
TypeLabelExpiryUploadedFile
${d.doc_type || 'other'} ${d.label} ${Fmt.date(d.expiry_date) || 'โ€”'} ${Fmt.date(d.created_at)} ${d.file_path ? `๐Ÿ“Ž View` : 'โ€”'} ${Auth.can('employees', 'delete') ? `` : ''}
`; } function openUploadDocModal(empId) { Modal.open({ id: 'upload-emp-doc', title: '๐Ÿ“Ž Upload Employee Document', body: `
`, footer: ` ` }); } async function submitEmpDoc(empId) { const btn = document.querySelector('#modal-upload-emp-doc .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('emp-doc-form'); const file = document.getElementById('emp-doc-file')?.files[0]; if (!file) { Toast.show('Please select a file.', 'error'); if (btn) btn.classList.remove('loading'); return; } const fd = new FormData(); fd.append('action', 'upload'); fd.append('document', file); fd.append('employee_id', empId); Object.entries(data).forEach(([k, v]) => { if (v !== '') fd.append(k, v); }); const res = await API.postForm('employees/documents', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Document uploaded.', 'success'); Modal.close(); loadEmpDocuments(empId); } else Toast.show(res.message, 'error'); } async function deleteEmpDoc(docId, empId) { if (!confirm('Delete this document?')) return; const res = await API.post('employees/documents', { action: 'delete', id: docId, employee_id: empId }); if (res.success) { Toast.show('Deleted.', 'success'); loadEmpDocuments(empId); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Warnings tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function loadEmpWarnings(empId) { const wrap = document.getElementById('emp-warnings-list'); if (!wrap) return; const res = await API.post('employees/warnings', { action: 'list', employee_id: empId }); const warn = res.data?.warnings || []; if (!warn.length) { wrap.innerHTML = `
โœ…
No warnings on record
`; return; } wrap.innerHTML = `
${warn.map(w => `
${Fmt.capitalize(w.warning_type.replace(/_/g, ' '))} ${Fmt.date(w.date_issued)} ยท ${w.issued_by_name || 'Admin'}
${!w.acknowledged_at ? `` : `โœ“ Acknowledged`} ${Auth.can('employees', 'delete') ? `` : ''}

Reason: ${w.reason}

${w.outcome ? `

Outcome: ${w.outcome}

` : ''} ${w.follow_up_date ? `

๐Ÿ“… Follow-up: ${Fmt.date(w.follow_up_date)}

` : ''}
`).join('')}
`; } function openAddWarningModal(empId) { Modal.open({ id: 'add-warning', title: 'โš ๏ธ Record Warning / Disciplinary', body: `
`, footer: ` ` }); } async function submitWarning(empId) { const btn = document.querySelector('#modal-add-warning .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('warning-form'); const res = await API.post('employees/warnings', { action: 'save', employee_id: empId, ...data }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Warning recorded.', 'success'); Modal.close(); loadEmpWarnings(empId); } else Toast.show(res.message, 'error'); } async function acknowledgeWarning(warnId, empId) { const res = await API.post('employees/warnings', { action: 'acknowledge', id: warnId, employee_id: empId }); if (res.success) { Toast.show('Acknowledged.', 'success'); loadEmpWarnings(empId); } else Toast.show(res.message, 'error'); } async function deleteWarning(warnId, empId) { if (!confirm('Delete this warning record?')) return; const res = await API.post('employees/warnings', { action: 'delete', id: warnId, employee_id: empId }); if (res.success) { Toast.show('Deleted.', 'success'); loadEmpWarnings(empId); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Link User โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function openLinkUserModal(empId) { Modal.open({ id: 'link-user', title: '๐Ÿ”— Link System User to Employee', body: `

Link a login so this employee can view their profile and apply for leave.

`, footer: ` ` }); } let _luTimer; function searchUsersForLink(val) { clearTimeout(_luTimer); const results = document.getElementById('link-user-results'); document.getElementById('link-user-id').value = ''; if (!val || val.length < 2) { if (results) results.innerHTML = ''; return; } _luTimer = setTimeout(async () => { const res = await API.post('users/search', { q: val, limit: 8 }); const users = res.data?.users || []; if (!results) return; results.innerHTML = users.length ? users.map(u => `
${u.full_name} @${u.username}
`).join('') : '
No users found
'; }, 300); } function selectUserLink(id, username, name) { document.getElementById('link-user-id').value = id; document.getElementById('link-user-search').value = username; document.getElementById('link-user-chosen').textContent = `โœ“ ${name} (@${username})`; document.getElementById('link-user-results').innerHTML = ''; } async function submitLinkUser(empId) { const userId = document.getElementById('link-user-id')?.value; const search = document.getElementById('link-user-search')?.value?.trim(); if (search && !userId) { Toast.show('Select a user from search results.', 'error'); return; } const res = await API.post('employees/link_user', { employee_id: empId, user_id: userId || '' }); if (res.success) { Toast.show('User linked.', 'success'); Modal.close(); renderEmployeeDetail(empId); } else Toast.show(res.message, 'error'); } async function unlinkUser(empId) { if (!confirm('Unlink this user?')) return; const res = await API.post('employees/link_user', { employee_id: empId, user_id: '' }); if (res.success) { Toast.show('Unlinked.', 'success'); renderEmployeeDetail(empId); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Admin Leave Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function renderLeaveAdmin() { const content = document.getElementById('page-content'); content.innerHTML = `

Leave Management

Approve and manage leave requests

${Auth.can('employees', 'reports') ? `` : ''}
`; await loadLeaveAdmin(); } async function loadLeaveAdmin() { const wrap = document.getElementById('leave-admin-table'); if (!wrap) return; const status = document.getElementById('leave-status-filter')?.value || ''; const type = document.getElementById('leave-type-filter')?.value || ''; const year = document.getElementById('leave-year-filter')?.value || new Date().getFullYear(); const res = await API.post('leave/list', { status, leave_type: type, year, limit: 60, page: 1 }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const leaves = res.data.leaves; if (!leaves.length) { wrap.innerHTML = `
๐Ÿ“‹
No leave requests
`; return; } wrap.innerHTML = `
${leaves.map(l => ` `).join('')}
EmployeeDept.TypeStartEndDaysReasonStatus
${l.employee_name}
${l.employee_number}
${l.department || 'โ€”'} ${Fmt.capitalize(l.leave_type)} ${Fmt.date(l.start_date)}${Fmt.date(l.end_date)} ${l.days} ${l.reason || 'โ€”'} ${Fmt.statusBadge(l.status)} ${l.status === 'pending' ? `
`: l.status === 'approved' ? `` : 'โ€”'}
`; } async function adminLeaveAction(id, action) { let reason = ''; if (action === 'reject') reason = prompt('Reason for rejection:') || ''; const res = await API.post('leave/action', { id, action, reason }); if (res.success) { Toast.show({ approve: 'Leave approved โœ“', reject: 'Leave rejected.', cancel: 'Leave cancelled.' }[action], 'success'); loadLeaveAdmin(); } else Toast.show(res.message, 'error'); } async function openLeaveReportModal() { Modal.open({ id: 'leave-report', title: '๐Ÿ“Š Leave Report', size: 'modal-xl', body: `
`, footer: `` }); const res = await API.post('leave/report', { year: new Date().getFullYear() }); const body = document.getElementById('leave-report-body'); if (!body || !res.success) return; const data = res.data.report; const empMap = {}; data.forEach(r => { if (!r.leave_type) return; if (!empMap[r.id]) empMap[r.id] = { name: r.employee_name, number: r.employee_number, dept: r.department, types: {} }; empMap[r.id].types[r.leave_type] = r; }); const types = ['annual', 'sick', 'family']; body.innerHTML = `
${types.map(t => ``).join('')}${types.map(() => '').join('')}${Object.values(empMap).map(e => ` ${types.map(t => { const b = e.types[t] || {}; const alloc = parseFloat(b.allocated || 0) + parseFloat(b.carried_over || 0); const used = parseFloat(b.used || 0); const avail = Math.max(0, alloc - used); return ``; }).join('')} `).join('')}
EmployeeDept.${Fmt.capitalize(t)}
AllocUsedAvail
${e.name} ${e.number} ${e.dept || 'โ€”'}${alloc.toFixed(1)}${used.toFixed(1)}${avail.toFixed(1)}
`; } // โ”€โ”€ HR Settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // renderHRSettings now delegates to the standalone renderSettings page async function renderHRSettings() { Router.navigate('settings'); } // โ”€โ”€ Settings Page (accessible from nav) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function renderSettings() { const content = document.getElementById('page-content'); const isAdmin = Auth.isHR(); // includes Admin (1) and HR/Management (5) content.innerHTML = `

Settings

System configuration

${isAdmin ? `
๐Ÿ‘ค Users
` : ''}
`; if (isAdmin) loadUsersTable(); const res = await API.post('settings/get', {}); const body = document.getElementById('settings-body'); if (!body || !res.success) return; const settings = res.data.settings; const groups = {}; settings.forEach(s => { if (!groups[s.setting_group]) groups[s.setting_group] = []; groups[s.setting_group].push(s); }); const groupLabels = { general: '๐Ÿข General', finance: '๐Ÿ’ฐ Finance & VAT', hr: '๐Ÿ‘ฅ HR & Payroll', leave: '๐ŸŒด Leave', stock: '๐Ÿ“ฆ Stock' }; body.innerHTML = `
${Object.entries(groups).map(([g, items]) => `
${groupLabels[g] || Fmt.capitalize(g) + ' Settings'}
${items.map(s => `
${s.input_type === 'checkbox' ? `` : ``}
`).join('')}
`).join('')}
${isAdmin ? `
โ›” Danger Zone
Reset All Data
Wipe all operational data to start fresh. Keeps users, settings & config tables.
` : ''}`; window._vatRate = null; } // โ”€โ”€ Users Table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Roles loaded dynamically from API async function getSystemRoles() { const res = await API.post('roles/list', {}); return res.data?.roles || [ { id: 1, name: 'Admin' }, { id: 2, name: 'Developer' }, { id: 3, name: 'QA' }, { id: 4, name: 'Technician' }, { id: 5, name: 'HR/Management' } ]; } // Keep legacy ROLES map for badges (updated after load) const ROLES = { 1: 'Admin', 2: 'Developer', 3: 'QA', 4: 'Technician', 5: 'HR/Management' }; const ROLE_COLORS = { 1: '#dc2626', 2: '#7c3aed', 3: '#0369a1', 4: '#15803d', 5: '#92400e' }; async function loadUsersTable() { const wrap = document.getElementById('users-table-wrap'); if (!wrap) return; // Load roles and users in parallel const [rolesData, res2] = await Promise.all([ getSystemRoles(), API.post('auth/users_list', { include_inactive: 1 }) ]); // Update ROLES map from live data rolesData.forEach(r => { ROLES[r.id] = r.name; }); const users = res2.data?.users || []; if (!users.length) { wrap.innerHTML = `
No users found.
`; return; } wrap.innerHTML = `
${users.map(u => ` `).join('')}
Name Username Email Role Status Last Login
${(u.full_name || '?').split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
${u.full_name || 'โ€”'}
${u.username} ${u.email || 'โ€”'} ${ROLES[u.role_id] || 'Unknown'} ${u.is_active ? 'โ— Active' : 'โ—‹ Inactive'} ${u.last_login ? Fmt.date(u.last_login) : 'Never'}
`; } // โ”€โ”€ Add User Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function openAddUserModal() { const roles = await getSystemRoles(); const roleOptions = roles.map(r => ``).join(''); Modal.open({ id: 'user-modal', title: '+ Add User', body: `
`, footer: ` ` }); } // โ”€โ”€ Edit User Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function openEditUserModal(u) { const roles = await getSystemRoles(); const roleOptions = roles.map(r => ``).join(''); Modal.open({ id: 'user-modal', title: `โœ๏ธ Edit โ€” ${u.full_name}`, body: `
`, footer: ` ` }); } // โ”€โ”€ Submit (create or update) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function submitUser(id) { const btn = document.querySelector('#modal-user-modal .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('user-form'); if (!data.full_name || !data.username || !data.email) { Toast.show('Name, username and email are required.', 'error'); if (btn) btn.classList.remove('loading'); return; } const endpoint = id ? 'auth/update_user' : 'auth/create_user'; const payload = id ? { id, ...data } : data; // Don't send blank password on update if (id && !payload.password) delete payload.password; const res = await API.post(endpoint, payload); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(id ? 'User updated. โœ…' : 'User created. โœ…', 'success'); Modal.close(); loadUsersTable(); } else { Toast.show(res.message, 'error'); } } async function saveSettings() { const btn = document.querySelector('button[onclick="saveSettings()"]'); if (btn) btn.classList.add('loading'); const data = getFormData('settings-form'); const updates = Object.entries(data).map(([key, value]) => ({ key, value: value === 'on' ? '1' : value || '0' })); document.querySelectorAll('#settings-form input[type=checkbox]').forEach(cb => { if (!cb.checked) { const u = updates.find(u => u.key === cb.name); if (u) u.value = '0'; else updates.push({ key: cb.name, value: '0' }); } }); const res = await API.post('settings/update', { updates }); if (btn) btn.classList.remove('loading'); if (res.success) { window._vatRate = null; // force re-fetch on next slip Toast.show('Settings saved.', 'success'); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Danger Zone โ€” Reset All Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function openResetModal() { Modal.open({ id: 'modal-reset', title: 'โš ๏ธ Reset All Data', size: 'md', body: `
โ›” This action is irreversible
This will permanently delete all operational data including:
Kept: Users, roles, settings, stock categories, tax tables & checklist templates.
`, footer: ` ` }); // Enable button only when typed correctly setTimeout(() => { const input = document.getElementById('reset-confirm-input'); const btn = document.getElementById('btn-confirm-reset'); if (input && btn) { input.addEventListener('input', () => { btn.disabled = input.value !== 'DELETE ALL DATA'; }); } }, 100); } async function executeReset() { const input = document.getElementById('reset-confirm-input'); const btn = document.getElementById('btn-confirm-reset'); if (!input || input.value !== 'DELETE ALL DATA') return; if (btn) btn.classList.add('loading'); const res = await API.post('admin/reset_data', { action: 'confirm_reset', confirm_text: 'DELETE ALL DATA' }); if (btn) btn.classList.remove('loading'); if (res.success) { Modal.close(); Toast.show(res.message, 'success', 5000); // Navigate to dashboard after short delay setTimeout(() => Router.navigate('dashboard'), 1500); } else { Toast.show(res.message || 'Reset failed.', 'error'); } } // โ”€โ”€ My Profile (self-service for employees) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function renderMyProfile() { const content = document.getElementById('page-content'); content.innerHTML = `
`; const res = await API.post('employees/my_profile', {}); if (!res.success) { content.innerHTML = `
๐Ÿ‘ค
No profile linked

Contact HR to link your employee profile.

`; return; } const e = res.data.employee; const leaveBalMap = {}; (e.leave_balances || []).forEach(b => { leaveBalMap[b.leave_type] = b; }); content.innerHTML = `

My Profile

${e.first_name} ${e.last_name}

${e.employee_number}

${e.job_title || 'โ€”'}

${e.department || 'โ€”'}

${e.work_email || 'โ€”'}

${e.phone || 'โ€”'}

${Fmt.date(e.start_date)}

${Fmt.capitalize(e.employment_type)}

My Leave Requests
Leave Balance (${new Date().getFullYear()})
${['annual', 'sick', 'family'].map(t => { const b = leaveBalMap[t]; const alloc = b ? parseFloat(b.allocated) + parseFloat(b.carried_over) : 0; const used = b ? parseFloat(b.used) : 0; const avail = Math.max(0, alloc - used); const pct = alloc > 0 ? Math.min(100, Math.round((used / alloc) * 100)) : 0; return `
${Fmt.capitalize(t)} Leave ${avail.toFixed(1)} days
`; }).join('')}
`; } async function loadMyLeave(empId) { const wrap = document.getElementById('my-leave-wrap'); if (!wrap) return; const res = await API.post('leave/list', { employee_id: empId, year: new Date().getFullYear(), limit: 30, page: 1 }); const leaves = res.data?.leaves || []; if (!leaves.length) { wrap.innerHTML = `
No leave requests
`; return; } wrap.innerHTML = `
${leaves.map(l => ``).join('')}
TypeStartEndDaysStatus
${Fmt.capitalize(l.leave_type)}${Fmt.date(l.start_date)}${Fmt.date(l.end_date)}${l.days}${Fmt.statusBadge(l.status)}
`; } // โ”€โ”€ Leave Apply Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function openLeaveApplyModal(empId) { Modal.open({ id: 'apply-leave', title: 'Apply for Leave', body: `
`, footer: ` ` }); } async function submitLeaveApplication(empId) { const btn = document.querySelector('#modal-apply-leave .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('leave-form'); const res = await API.post('leave/apply', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(`Leave applied (${res.data.days} day(s)).`, 'success'); Modal.close(); loadEmpLeaveDetail(empId); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Add/Edit Employee โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function openAddEmployeeModal() { Modal.open({ id: 'add-employee', title: 'Add Employee', size: 'modal-xl', body: buildEmployeeForm(), footer: ` ` }); } async function openEditEmployeeModal(id) { const res = await API.post('employees/get', { id }); if (!res.success) { Toast.show(res.message, 'error'); return; } Modal.open({ id: 'edit-employee', title: 'Edit Employee', size: 'modal-xl', body: buildEmployeeForm(res.data.employee), footer: ` ` }); } function buildEmployeeForm(e = {}) { return `
${e.id ? `` : ''}

Override global defaults for this employee. Used for labour cost calculations.

Hourly rate = Monthly salary รท (days ร— hours)

`; } async function submitEmployee(id = 0) { const modalId = id ? 'edit-employee' : 'add-employee'; const btn = document.querySelector(`#modal-${modalId} .btn-primary`); if (btn) btn.classList.add('loading'); const data = getFormData('employee-form'); const res = await API.post(id ? 'employees/update' : 'employees/create', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(id ? 'Employee updated.' : 'Employee created.', 'success'); Modal.close(); id ? renderEmployeeDetail(id) : loadEmployees(empState.page); } else Toast.show(res.message, 'error'); } // โ”€โ”€ Salary modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function openSalaryModal(empId) { Modal.open({ id: 'update-salary', title: 'Update Salary', body: `
`, footer: ` ` }); } async function submitSalaryUpdate(empId) { const btn = document.querySelector('#modal-update-salary .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('salary-form'); const res = await API.post('employees/salary_add', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Salary updated.', 'success'); Modal.close(); renderEmployeeDetail(empId); } else Toast.show(res.message, 'error'); }