// ============================================================ // Clients Module // ============================================================ let clientsState = { page: 1, search: '', status: '', clients: [], pagination: null }; async function renderClients(params = {}) { const content = document.getElementById('page-content'); if (params.id) return renderClientDetail(params.id); const _cc = { v: Auth.can('clients', 'view'), c: Auth.can('clients', 'create'), e: Auth.can('clients', 'edit'), d: Auth.can('clients', 'delete') }; content.innerHTML = `

All Clients

Manage client relationships and contact information

${Auth.can('clients', 'create') ? `` : ''}
`; await loadClients(clientsState.page); } const clientSearchDebounced = debounce(val => { clientsState.search = val; loadClients(1); }, 350); async function loadClients(page = 1) { clientsState.page = page; const wrap = document.getElementById('clients-table-wrap'); if (!wrap) return; try { const res = await API.post('clients/list', { page, limit: 20, search: clientsState.search, status: clientsState.status, }); if (!res.success) throw new Error(res.message); clientsState.clients = res.data.clients; clientsState.pagination = res.data.pagination; if (!res.data.clients.length) { wrap.innerHTML = `
No clients found

Try adjusting your search or add a new client.

`; document.getElementById('clients-pagination').innerHTML = ''; return; } if (window.innerWidth < 768) { wrap.innerHTML = `
${res.data.clients.map(c => `
${c.company_name}
${c.trading_name ? `
${c.trading_name}
` : ''}
${c.industry || '—'} · ${c.contact_count || 0} contacts
${Fmt.statusBadge(c.status)}
`).join('')}
`; } else { wrap.innerHTML = `
${res.data.clients.map(c => ` `).join('')}
CompanyIndustryContactsProjectsStatusActions
${c.company_name}
${c.trading_name ? `
${c.trading_name}
` : ''}
${c.industry || '—'} ${c.contact_count || 0} ${c.project_count || 0} ${Fmt.statusBadge(c.status)}
${Auth.can('clients', 'edit') ? `
`; } renderPagination('clients-pagination', res.data.pagination, `p => loadClients(p)`); } catch (e) { wrap.innerHTML = `
Error loading clients

${e.message}

`; } } function openAddClientModal() { Modal.open({ id: 'add-client', title: 'Add New Client', size: 'modal-lg', body: `
Primary Contact (Optional)
`, footer: ` ` }); } async function submitAddClient() { const btn = document.querySelector('#modal-add-client .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('add-client-form'); const res = await API.post('clients/create', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Client created successfully.', 'success'); Modal.close(); loadClients(1); } else { Toast.show(res.message, 'error'); } } async function openEditClientModal(id) { const res = await API.post('clients/get', { id }); if (!res.success) { Toast.show(res.message, 'error'); return; } const c = res.data.client; Modal.open({ id: 'edit-client', title: `Edit — ${c.company_name}`, size: 'modal-lg', body: `
`, footer: ` ` }); } async function submitEditClient() { const btn = document.querySelector('#modal-edit-client .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('edit-client-form'); const res = await API.post('clients/update', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Client updated.', 'success'); Modal.close(); loadClients(clientsState.page); } else Toast.show(res.message, 'error'); } async function renderClientDetail(id) { const content = document.getElementById('page-content'); content.innerHTML = '
'; const res = await API.post('clients/get', { id }); if (!res.success) { Toast.show(res.message, 'error'); return; } const c = res.data.client; content.innerHTML = `

${c.company_name}

${c.trading_name || ''} ${c.registration_no ? '· Reg: ' + c.registration_no : ''}
${Fmt.statusBadge(c.status)} ${Auth.can('clients', 'edit') ? `` : ''}
Industry

${c.industry || '—'}

VAT No.

${c.vat_no || '—'}

Website

${c.website ? `${c.website}` : '—'}

Created

${Fmt.date(c.created_at)}

${c.notes ? `
Notes

${c.notes}

` : ''}
Contacts
${Auth.can('clients', 'create') ? `` : ''}
${c.contacts?.length ? c.contacts.map(ct => `
${avatarHTML(ct.full_name, 'lg')}
${ct.full_name} ${ct.is_primary ? 'Primary' : ''}
${ct.position || ''}
${ct.email || ''} ${ct.phone ? '· ' + ct.phone : ''}
`).join('') : `
No contacts yet
`}
Notes
${Auth.can('clients', 'create') ? `` : ''}
${c.notes?.length ? c.notes.map(n => `
${avatarHTML(n.user_name)} ${n.user_name} ${Fmt.capitalize(n.note_type)}
${Fmt.ago(n.created_at)}
${n.title ? `
${n.title}
` : ''}

${n.body}

`).join('') : `
No notes yet
`}
${c.projects?.length ? `
${c.projects.map(p => ``).join('')}
ProjectStatusPriorityTarget Date
${p.name} ${Fmt.statusBadge(p.status)} ${Fmt.priorityBadge(p.priority)} ${Fmt.date(p.target_date)}
` : `
No projects yet
`}
Client Assets
${Auth.can('clients', 'create') ? `` : ''}
Email Accounts
${Auth.can('clients', 'create') ? `` : ''}
🔒 Passwords are encrypted at rest (AES-256). Access is logged.
Password Vault
${Auth.can('clients', 'create') ? `` : ''}
🔒 All credentials encrypted (AES-256). Only authorised staff can view passwords.
Documents
${Auth.can('clients', 'create') ? `` : ''}

Quick Stats

Projects${c.projects?.length || 0}
Recent Jobs${c.recent_jobs?.length || 0}
Client Since${Fmt.date(c.created_at)}
${c.recent_jobs?.length ? `

Recent Job Cards

${c.recent_jobs.map(j => `
${j.job_number}
${j.title}
${Fmt.statusBadge(j.status)}
`).join('')}
` : ''}
`; } // switchTab is defined globally in router.js function openAddContactModal(clientId) { Modal.open({ id: 'add-contact', title: 'Add Contact', body: `
`, footer: `` }); } async function submitAddContact(clientId) { const data = getFormData('add-contact-form'); const res = await API.post('clients/add_contact', data); if (res.success) { Toast.show('Contact added.', 'success'); Modal.close(); renderClientDetail(clientId); } else Toast.show(res.message, 'error'); } function openAddNoteModal(clientId) { Modal.open({ id: 'add-note', title: 'Add Note', body: `
`, footer: `` }); } async function submitAddNote(clientId) { const data = getFormData('add-note-form'); const res = await API.post('clients/add_note', data); if (res.success) { Toast.show('Note added.', 'success'); Modal.close(); renderClientDetail(clientId); } else Toast.show(res.message, 'error'); } // ============================================================ // CLIENT ASSETS // ============================================================ async function loadClientAssets(clientId) { const wrap = document.getElementById('client-assets-list'); if (!wrap) return; const res = await API.post('clients/assets_list', { client_id: clientId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const assets = res.data.assets; if (!assets.length) { wrap.innerHTML = `
🖥️
No assets recorded
`; return; } const typeIcon = { computer: '🖥️', laptop: '💻', server: '🗄️', network: '🌐', printer: '🖨️', phone: '📱', tablet: '📋', ups: '🔋', camera: '📷', other: '📦' }; wrap.innerHTML = `
${assets.map(a => `
${typeIcon[a.asset_type] || '📦'}
${a.name}
${a.brand || ''} ${a.model || ''}
${Fmt.statusBadge(a.status)}
${a.serial_number ? `
S/N

${a.serial_number}

` : ''} ${a.location_desc ? `
Location

${a.location_desc}

` : ''} ${a.warranty_expiry ? `
Warranty

${Fmt.date(a.warranty_expiry)}

` : ''}
${a.notes ? `

${a.notes}

` : ''}
`).join('')}
`; } function openAddAssetModal(clientId, asset = null) { const isEdit = !!asset; Modal.open({ id: isEdit ? 'edit-asset' : 'add-asset', title: isEdit ? 'Edit Asset' : 'Add Asset', size: 'modal-lg', body: `
${isEdit ? `` : ''}
`, footer: ` ` }); } async function openEditAssetModal(assetId, clientId) { // Fetch full asset from the list already rendered, or just open with id const res = await API.post('clients/assets_list', { client_id: clientId }); if (!res.success) return; const asset = res.data.assets.find(a => a.id == assetId); if (asset) openAddAssetModal(clientId, asset); } async function submitAsset(clientId) { const btn = document.querySelector('[id^="modal-"] .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('asset-form'); const res = await API.post('clients/asset_save', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(data.id ? 'Asset updated.' : 'Asset added.', 'success'); Modal.close(); loadClientAssets(clientId); } else Toast.show(res.message, 'error'); } async function deleteAsset(assetId, clientId) { const ok = await Modal.confirm('Delete this asset?', 'This cannot be undone.'); if (!ok) return; const res = await API.post('clients/asset_save', { action: 'delete', id: assetId, client_id: clientId }); if (res.success) { Toast.show('Asset deleted.', 'success'); loadClientAssets(clientId); } else Toast.show(res.message, 'error'); } // ============================================================ // CLIENT EMAIL ACCOUNTS // ============================================================ async function loadClientEmails(clientId) { const wrap = document.getElementById('client-emails-list'); if (!wrap) return; const res = await API.post('clients/emails', { action: 'list', client_id: clientId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const accounts = res.data.accounts; if (!accounts.length) { wrap.innerHTML = `
📧
No email accounts saved
`; return; } wrap.innerHTML = accounts.map(a => `
${a.email_address}
${a.display_name || ''} ${a.provider ? '· ' + a.provider : ''}
Username

${a.username || '—'}

Password
••••••••
${a.webmail_url ? `
Webmail${a.webmail_url}
` : ''}
IMAP: ${a.imap_host || '—'}:${a.imap_port} ${a.imap_ssl ? 'SSL' : ''}
SMTP: ${a.smtp_host || '—'}:${a.smtp_port} ${a.smtp_ssl ? 'SSL' : ''}
`).join(''); } function toggleEmailPw(id, plain) { const el = document.getElementById(`epw-${id}`); if (!el) return; el.textContent = el.textContent === '••••••••' ? (plain || '(empty)') : '••••••••'; } function openAddEmailModal(clientId, account = null) { const a = account; Modal.open({ id: a ? 'edit-email' : 'add-email', title: a ? 'Edit Email Account' : 'Add Email Account', size: 'modal-lg', body: `
${a ? `` : ''}
Server Settings
`, footer: ` ` }); } async function submitEmailAccount(clientId) { const data = getFormData('email-form'); data.action = 'save'; const res = await API.post('clients/emails', data); if (res.success) { Toast.show('Saved.', 'success'); Modal.close(); loadClientEmails(clientId); } else Toast.show(res.message, 'error'); } async function deleteEmail(id, clientId) { const ok = await Modal.confirm('Delete this email account?'); if (!ok) return; const res = await API.post('clients/emails', { action: 'delete', id, client_id: clientId }); if (res.success) { Toast.show('Deleted.', 'success'); loadClientEmails(clientId); } else Toast.show(res.message, 'error'); } // ============================================================ // CLIENT PASSWORD VAULT // ============================================================ const CAT_ICONS = { website: '🌐', server: '🖥️', database: '🗄️', email: '📧', network: '🌐', hosting: '☁️', cpanel: '⚙️', social: '📱', other: '🔑' }; async function loadClientPasswords(clientId) { const wrap = document.getElementById('client-passwords-list'); if (!wrap) return; const res = await API.post('clients/passwords', { action: 'list', client_id: clientId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const pwds = res.data.passwords; if (!pwds.length) { wrap.innerHTML = `
🔑
No passwords saved
`; return; } wrap.innerHTML = `
${pwds.map(p => ` `).join('')}
LabelCategoryUsernamePasswordURL
${CAT_ICONS[p.category] || '🔑'} ${p.label} ${p.category} ${p.username || '—'}
••••••••
${p.url ? `🔗 Open` : '—'}
`; } function togglePwVisible(id, plain) { const el = document.getElementById(`pw-${id}`); if (!el) return; el.textContent = el.textContent === '••••••••' ? (plain || '(empty)') : '••••••••'; } function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => Toast.show('Copied to clipboard.', 'success')); } function openAddPasswordModal(clientId, pwd = null) { const p = pwd; Modal.open({ id: p ? 'edit-pwd' : 'add-pwd', title: p ? 'Edit Password' : 'Add Password', body: `
${p ? `` : ''}
`, footer: ` ` }); } async function submitPassword(clientId) { const data = getFormData('pwd-form'); const res = await API.post('clients/passwords', data); if (res.success) { Toast.show('Password saved.', 'success'); Modal.close(); loadClientPasswords(clientId); } else Toast.show(res.message, 'error'); } async function deletePwd(id, clientId) { const ok = await Modal.confirm('Delete this password?'); if (!ok) return; const res = await API.post('clients/passwords', { action: 'delete', id, client_id: clientId }); if (res.success) { Toast.show('Deleted.', 'success'); loadClientPasswords(clientId); } else Toast.show(res.message, 'error'); } // ============================================================ // CLIENT DOCUMENTS // ============================================================ async function loadClientDocuments(clientId) { const wrap = document.getElementById('client-docs-wrap'); if (!wrap) return; const res = await API.post('clients/documents', { action: 'list', client_id: clientId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const docs = res.data.documents; const today = new Date().toISOString().split('T')[0]; if (!docs.length) { wrap.innerHTML = `
📄
No documents yet
`; return; } const iconFor = mime => { if (!mime) return 'DOC'; if (mime.includes('pdf')) return 'PDF'; if (mime.includes('image')) return 'IMG'; if (mime.includes('word') || mime.includes('document')) return 'DOC'; if (mime.includes('excel') || mime.includes('sheet')) return 'XLS'; return 'DOC'; }; wrap.innerHTML = `
${docs.map(d => { const expired = d.expiry_date && d.expiry_date < today; const expireSoon = d.expiry_date && !expired && d.expiry_date <= new Date(Date.now() + 30 * 864e5).toISOString().split('T')[0]; const expiryBadge = expired ? `❌ Expired ${Fmt.date(d.expiry_date)}` : expireSoon ? `⚠ ${Fmt.date(d.expiry_date)}` : d.expiry_date ? `${Fmt.date(d.expiry_date)}` : '—'; return ``; }).join('')}
DocumentTypeExpiryUploaded ByDate
${iconFor(d.mime_type)}
${d.description || d.original_name || d.filename}
${d.notes ? `
${d.notes}
` : ''}
${d.doc_type || 'other'} ${expiryBadge} ${d.uploaded_by_name || '—'} ${Fmt.date(d.created_at)} ${d.file_path ? `View` : ''} ${Auth.can('clients', 'delete') ? `` : ''}
`; } function openUploadDocModal(clientId) { Modal.open({ id: 'cl-doc-upload', title: 'Upload Document', body: `
`, footer: ` ` }); } async function submitClientDoc(clientId) { const btn = document.querySelector('#modal-cl-doc-upload .btn-primary'); if (btn) btn.classList.add('loading'); const form = document.getElementById('cl-doc-form'); const fd = new FormData(form); fd.append('action', 'upload'); const res = await API.upload('clients/documents', fd); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Document uploaded!', 'success'); Modal.close(); loadClientDocuments(clientId); } else Toast.show(res.message, 'error'); } async function deleteClientDoc(id, clientId) { if (!await Modal.confirm('Delete this document?', 'The file will be permanently removed.')) return; const res = await API.post('clients/documents', { action: 'delete', id, client_id: clientId }); if (res.success) { Toast.show('Deleted.', 'success'); loadClientDocuments(clientId); } else Toast.show(res.message, 'error'); }