'; savePortalBlob(new Blob(['\ufeff',table],{type:'application/vnd.ms-excel;charset=utf-8'}),filename); } payDownloadMsg.innerHTML='

Downloaded '+selected.length+' payment '+(selected.length===1?'record.':'records.')+'

'; }catch(e){payDownloadMsg.innerHTML='

'+esc(e.message)+'

'} finally{button.disabled=false;button.textContent=old} }; payExportPdf.onclick=()=>exportPayroll('pdf',payExportPdf);payExportExcel.onclick=()=>exportPayroll('excel',payExportExcel); }catch(e){v.innerHTML='

Payroll & Team Payments

'+esc(e.message)+'

'} } async function expensesView(){ markAlertsRead('expenses'); v.innerHTML='

Business Expenses

Loading expense records…

'; try{ await verifyReturnedCollection(); const E=await A('/expenses'),items=E.expenses||[],members=E.members||[],money=c=>window.PortalMoney?PortalMoney.html(c):new Intl.NumberFormat('en-CA',{style:'currency',currency:'CAD',currencyDisplay:'code'}).format((Number(c)||0)/100); const rows=items.length?items.map(x=>{ const outgoing=x.direction==='client_to_member'; const title=outgoing?'Client charge to '+x.member_name:x.member_name+' reimbursement claim'; const squarePay=outgoing&&x.payment_method==='square'&&x.status!=='paid'&&x.square_payment_url?'':''; const actions='
'+(x.receipt_url?'':'')+squarePay+(outgoing&&x.payment_method==='stripe'?localCurrencyPayment('expense',x,E.stripePayments||[]):'')+(E.manager&&x.status==='submitted'?'':'')+(E.manager&&['submitted','approved'].includes(x.status)&&!['square','stripe'].includes(x.payment_method)?'':'')+(!E.manager&&x.created_by===U.id&&x.status==='submitted'?'':'')+'
'; return '
'+esc(title)+'
'+esc(x.description)+'
'+esc(x.status)+''+money(x.amount_cents)+''+esc(x.category)+''+esc(x.payment_method==='stripe'?'Stripe — local currency':x.payment_method==='square'?'Square':x.payment_method==='etransfer'?'e-Transfer':x.payment_method||'External payment')+'Due '+displayDate(x.due_date)+'
'+(x.payment_reference?'

Payment reference: '+esc(x.payment_reference)+'

':'')+(x.notes?'

'+esc(x.notes)+'

':'')+'
'+actions+'
'; }).join(''):'
No business expenses have been recorded.
'; const memberOptions=members.map(x=>'').join(''); const form=E.manager ?'

Create business expense

Charge a team member for a business expense or record a reimbursement claim on their behalf. These records are separate from portal and service billing.

'+expenseFields()+'
' :'

Submit an expense to the client

Request reimbursement for an approved business expense. This does not affect portal membership or WellSpoke service billing.

'+expenseFields()+'
'; v.innerHTML='
Business expense ledger
Square is the default for charges owed to the client. Choose Stripe only when foreign-currency payment is requested. Reimbursements owed to team members are sent separately and recorded here.
'+form+'

Expense records

'+rows+'
'; function expenseFields(){return '

'} const setExpensePaymentMethods=()=>{const outgoing=E.manager&&expenseDirection.value==='client_to_member';expensePaymentMethod.innerHTML=outgoing?'':'';expensePaymentHelp.textContent=outgoing?'Payments go to the workspace’s connected account. Square is the default. Choose Stripe when foreign-currency payment is requested; supported currencies and conversion costs are shown before payment.':'The client sends the reimbursement separately, then records it as paid.'}; setExpensePaymentMethods();if(E.manager)expenseDirection.onchange=setExpensePaymentMethods; markPeriodList('business-expenses',v.querySelector('.expense-register'),v.querySelectorAll('.expenseRow'),items,x=>x.created_at||x.due_date); saveExpense.onclick=async()=>{expenseMsg.innerHTML='';saveExpense.disabled=true;try{const file=expenseReceiptFile.files[0];let receipt='';if(file){if(file.size>5*1024*1024)throw Error('Receipt must be 5 MB or smaller');receipt=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=()=>reject(Error('Receipt could not be read'));reader.readAsDataURL(file)})}await A('/expenses',{method:'POST',body:JSON.stringify({direction:E.manager?expenseDirection.value:'member_to_client',memberUserId:E.manager?expenseMember.value:U.id,description:expenseDescription.value,category:expenseCategory.value,amount:Number(expenseAmount.value),dueDate:expenseDue.value,paymentMethod:expensePaymentMethod.value,notes:expenseNotes.value,receipt,receiptName:file?.name||''})});await expensesView();alert(E.manager?'Business expense created successfully.':'Expense claim submitted to the client.')}catch(e){expenseMsg.innerHTML='

'+esc(e.message)+'

'+connectionErrorLink(e,expensePaymentMethod.value==='stripe'?'stripe':'square')}finally{saveExpense.disabled=false}}; document.querySelectorAll('.expenseReceipt').forEach(b=>b.onclick=()=>window.open(apiUrl('/expenses?action=file&id='+encodeURIComponent(b.dataset.id)),'_blank','noopener')); document.querySelectorAll('.expenseStatus').forEach(b=>b.onclick=async()=>{try{await A('/expenses/'+encodeURIComponent(b.dataset.id),{method:'PATCH',body:JSON.stringify({status:b.dataset.status})});await expensesView()}catch(e){alert(e.message)}}); document.querySelectorAll('.expenseSquarePay').forEach(b=>b.onclick=()=>window.location.href=b.dataset.url); document.querySelectorAll('.expenseSquareCheck').forEach(b=>b.onclick=async()=>{const old=b.textContent;b.disabled=true;b.textContent='Checking…';try{const result=await A('/expenses/'+encodeURIComponent(b.dataset.id)+'/check-square',{method:'POST',body:'{}'});if(result.paid){await expensesView();alert('Square payment confirmed.')}else alert('Square has not reported this expense as paid yet.')}catch(e){alert(e.message)}finally{if(document.body.contains(b)){b.disabled=false;b.textContent=old}}}); }catch(e){v.innerHTML='

Business Expenses

'+esc(e.message)+'

'} } async function billingView(){ markAlertsRead('invoices'); v.innerHTML='

Billing

Loading billing information…

'; try{ await verifyReturnedCollection(); const B=await A('/billing'); const s=B.billing||{}, invoices=B.invoices||[], allocations=B.allocations||[]; /* Some older API responses included card details without cardOnFile. Treat either signal as authoritative so an existing card can always be replaced. */ const hasStoredCard=!!(s.cardOnFile||s.card?.last4); const money=c=>window.PortalMoney?PortalMoney.html(c):new Intl.NumberFormat('en-CA',{style:'currency',currency:'CAD',currencyDisplay:'code'}).format((Number(c)||0)/100); if(!B.billingManager){ const assigned=allocations.length?allocations.map(a=>'
'+esc(a.invoice_number)+'
'+esc(a.description)+'
'+esc(a.status)+''+money(a.amount_cents)+'Due '+displayDate(a.due_date)+'
'+((invoices.find(i=>i.id===a.invoice_id)?.payment_method==='stripe')?localCurrencyPayment('allocation',a,B.stripePayments||[]):'')+'
').join(''):'
No active team-member billing allocations.
'; v.innerHTML='

Your service contribution

These amounts were assigned to you by your workspace administrator. If an assigned amount is not paid, the client remains responsible for the unpaid invoice balance.

'+assigned+'
'; markPeriodList('member-billing',v.querySelector('.card'),v.querySelectorAll('.invoiceRow'),allocations,x=>x.created_at||x.due_date); return; } const status=U.role==='admin'?'':'
'+(s.access?'Portal access active':'Portal access restricted')+''+(s.trialActive?'Trial active until '+displayDate(s.trialEndsAt):s.accessOverride?'Owner override active':s.paidCurrent?(s.stripeBilling?'Account paid through Stripe':hasStoredCard?'Account paid and card on file':'Payment received — card on file still required'):'Payment is required')+'
'; const card=hasStoredCard?'
'+esc(s.card?.brand||'Card')+' •••• '+esc(s.card?.last4||'')+'Expires '+String(s.card?.expMonth||'').padStart(2,'0')+'/'+esc(s.card?.expYear||'')+'
':'
No payment card is stored with Square.
'; const rows=invoices.length?invoices.map(x=>'
'+esc(x.invoice_number)+'
'+esc(x.description)+'
'+esc(x.status)+''+money(x.amount_cents)+''+esc(x.payment_method==='stripe'?'Stripe — local currency':x.payment_method==='etransfer'?'e-Transfer':'Square')+''+esc(x.recurrence.replaceAll('_',' '))+'Due '+displayDate(x.due_date)+'
'+(x.payment_method==='stripe'?localCurrencyPayment('invoice',x,B.stripePayments||[]):'')+(U.role==='admin'?'
'+(x.payment_method!=='stripe'&&x.status!=='paid'&&x.status!=='cancelled'?'':'')+(x.payment_method==='square'&&x.status!=='paid'&&x.status!=='cancelled'?'':'')+(x.status!=='cancelled'?'':'')+'
':'')+'
').join(''):'
No invoices yet.
'; const paymentTerms='

Payment terms: Payments must be made within seven days of receiving the invoice.

'; if(U.role==='admin'){ v.innerHTML=status+'

Create invoice

Square is the default. Select Stripe only for requested foreign-currency payment. Display estimates do not change the payment method. Enter the original amount in CAD; Stripe confirms eligible local-currency prices at checkout. Recurring Stripe invoices are issued on schedule and paid by the customer each period.

'+paymentTerms+'

Client access

'+card+'

During an active trial the client can use the portal without payment. After the trial, the account must be current. Stripe customers pay invoices at checkout; Square automatic billing requires a stored card.

Invoices

'+paymentTerms+rows+'
'; markPeriodList('invoices',v.querySelector('.ws-invoices-panel'),v.querySelectorAll('.ws-invoices-panel > .invoiceRow'),invoices,x=>x.created_at||x.due_date); createInvoice.onclick=async()=>{invMsg.textContent='';try{await A('/billing?action=create-invoice',{method:'POST',body:JSON.stringify({description:invDesc.value,amount:Number(invAmount.value),paymentMethod:invMethod.value,recurrence:invRec.value,dueDate:invDue.value})});await refreshData({force:true});billingView()}catch(e){invMsg.innerHTML='

'+esc(e.message)+'

'}}; grantTrial.onclick=async()=>{try{await A('/billing?action=trial',{method:'POST',body:JSON.stringify({weeks:Number(trialWeeks.value)})});await refreshData({force:true});billingView()}catch(e){alert(e.message)}}; document.querySelectorAll('.invPaid').forEach(b=>b.onclick=async()=>{await A('/billing?action=mark-paid',{method:'POST',body:JSON.stringify({id:b.dataset.id})});await refreshData({force:true});billingView()}); document.querySelectorAll('.invCharge').forEach(b=>b.onclick=async()=>{try{const r=await A('/billing?action=charge',{method:'POST',body:JSON.stringify({id:b.dataset.id})});if(!r.ok)throw Error(r.error||'Charge failed');await refreshData({force:true});billingView()}catch(e){alert(e.message)}}); document.querySelectorAll('.invCancel').forEach(b=>b.onclick=async()=>{if(!confirm('Cancel this invoice or recurring billing item?'))return;await A('/billing?action=cancel',{method:'POST',body:JSON.stringify({id:b.dataset.id})});billingView()}); }else if(s.selfServe){ v.innerHTML=status+'

Separately agreed service invoices

Portal service subscriptions are managed above. Any separately agreed advisory or other invoices appear here.

'+rows+'
'; }else{ const cardForm=B.square?.configured?'
'+(hasStoredCard?'

Enter replacement card details

':'')+'
':'

Square card payments are not configured yet. Please contact WellSpoke HR.

'; v.innerHTML=status+'

Square automatic billing

'+card+(hasStoredCard?'

Your existing card remains active until the replacement is saved successfully.

':'')+'

Your card details are entered directly into Square and are not stored on WellSpoke HR servers. A stored Square card is required only for agreed Square automatic charges. Stripe invoices are paid separately at checkout.

'+cardForm+'

Payment methods

'+providerBrand('stripe')+'

Pay eligible invoices in your local currency. Stripe displays the converted amount and conversion costs before payment. Recurring Stripe invoices require payment each period.

Square
Card payments and agreed automatic recurring charges.

e-Transfer
Send payment using the e-Transfer instructions provided by WellSpoke HR. Access is updated after payment is confirmed.

'+paymentTerms+'
'+squareBrandMark()+'

Your invoices

'+rows+'
'; markPeriodList('invoices',v.querySelector('.ws-invoices-panel'),v.querySelectorAll('.ws-invoices-panel > .invoiceRow'),invoices,x=>x.created_at||x.due_date); if(B.square?.configured&&window.Square){ try{ const payments=window.Square.payments(B.square.applicationId,B.square.locationId); const sqCard=await payments.card(); await sqCard.attach('#squareCard'); const saveCardButton=document.getElementById('saveCard'); const cardMessage=document.getElementById('cardMsg'); const cardAuthorization=document.getElementById('cardConsent'); if(!saveCardButton||!cardMessage||!cardAuthorization)throw Error('The card form did not initialize correctly. Please refresh and try again.'); saveCardButton.addEventListener('click',async(event)=>{ event.preventDefault(); event.stopPropagation(); cardMessage.textContent=''; if(!cardAuthorization.checked){ cardMessage.innerHTML='

Authorization is required before saving a card.

'; alert('Please select the authorization checkbox before saving your card.'); return; } try{ saveCardButton.disabled=true; saveCardButton.textContent='Saving card…'; const result=await sqCard.tokenize(); if(result.status!=='OK')throw Error(result.errors?.[0]?.message||'Card could not be tokenized'); const saved=await A('/billing?action=save-card',{method:'POST',body:JSON.stringify({sourceId:result.token})}); if(!saved?.ok||!saved?.billing?.cardOnFile)throw Error(saved?.error||'Square did not confirm that the card was saved. Please try again.'); const savedCard=saved.billing.card||{}; const confirmation='Card saved successfully: '+(savedCard.brand||'Card')+' ending in '+(savedCard.last4||'')+'.'; cardMessage.innerHTML='

'+esc(confirmation)+'

'; saveCardButton.textContent='Card saved'; alert(confirmation); await new Promise(resolve=>setTimeout(resolve,1200)); await refreshData({force:true}); await billingView(); }catch(e){ const failure='Card was not saved: '+(e?.message||'Unknown error'); cardMessage.innerHTML='

'+esc(failure)+'

'; saveCardButton.disabled=false; saveCardButton.textContent=hasStoredCard?'Replace payment card':'Save card securely'; alert(failure); } }); }catch(e){document.getElementById('cardMsg').innerHTML='

'+esc(e.message)+'

'} } } if(window.PortalPlans)await PortalPlans.mount(v,A); const activeInvoices=invoices.filter(x=>!['paid','cancelled'].includes(x.status)); const team=(D.teamMembers||[]).filter(x=>x.active&&x.id!==U.id); const allocationRows=allocations.length?allocations.map(a=>'
'+esc(a.user_name)+'
'+esc(a.invoice_number)+' · '+money(a.amount_cents)+'
'+esc(a.status)+'Due '+displayDate(a.due_date)+'
'+(a.status!=='paid'?'':'')+(a.status==='unpaid'?'':'')+'
').join(''):'
No team-member billing allocations.
'; v.insertAdjacentHTML('beforeend','

Team-member billing

Assign part of an invoice to a team member. The client remains responsible for any amount the team member does not pay.

Current allocations

'+allocationRows+'
'); saveAllocation.onclick=async()=>{try{await A('/billing?action=allocate-member',{method:'POST',body:JSON.stringify({invoiceId:allocationInvoice.value,userId:allocationMember.value,amount:Number(allocationAmount.value)})});await refreshData({force:true});billingView()}catch(e){allocationMsg.innerHTML='

'+esc(e.message)+'

'}}; const allocationStatus=async(id,status)=>{await A('/billing?action=allocation-status',{method:'POST',body:JSON.stringify({id,status})});await refreshData({force:true});billingView()}; document.querySelectorAll('.allocationPaid').forEach(b=>b.onclick=()=>allocationStatus(b.dataset.id,'paid')); document.querySelectorAll('.allocationFailed').forEach(b=>b.onclick=()=>allocationStatus(b.dataset.id,'failed')); document.querySelectorAll('.allocationCancel').forEach(b=>b.onclick=()=>allocationStatus(b.dataset.id,'cancelled')); }catch(e){v.innerHTML='

Billing

'+esc(e.message)+'

'} } let clientWorkspaceNotice=null; function clientTrialSummary(trialEndsAt){ if(!trialEndsAt)return {className:'',text:'No active trial'}; const end=new Date(trialEndsAt); if(Number.isNaN(end.getTime()))return {className:'',text:'No active trial'}; const now=new Date(); const days=Math.max(0,Math.ceil((end.getTime()-now.getTime())/86400000)); if(end.getTime()<=now.getTime())return {className:'ended',text:'Trial ended '+displayDate(trialEndsAt)}; if(days<=7)return {className:'ending',text:'Trial ends '+displayDate(trialEndsAt)+' · '+days+' day'+(days===1?'':'s')+' remaining'}; return {className:'active',text:'Trial ends '+displayDate(trialEndsAt)+' · '+days+' days remaining'}; } function clientsView(){ const clients=WS.filter(x=>x.id!==U.orgId); const today=localDateString(new Date()); const savedNotice=clientWorkspaceNotice; clientWorkspaceNotice=null; v.innerHTML= (savedNotice?'
Changes saved successfully'+esc(savedNotice)+'
':'')+ '
'+ '
'+ '

Create client workspace

'+ '

Create the client and define their billing terms at the same time. The client receives an activation email after the workspace is created.

'+ '
'+ ''+ ''+ ''+ ''+ ''+ ''+ '
Billing criteria
'+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ '
Client agreement
'+ ''+ ''+ '

Optional. The agreement will be available for review and electronic signature immediately after the client activates their account, even before payment.

'+ ''+ '
'+ '
'+ '
'+ '
'+ '

Client workspaces

'+ (clients.length ?clients.map(x=>{ const trial=clientTrialSummary(x.trial_ends_at); return '
'+ '
'+ ''+esc(x.name)+''+ '
'+ (x.primary_contact?esc(x.primary_contact)+' · ':'')+ (x.primary_email?esc(x.primary_email)+' · ':'')+ (x.client_users||0)+' active portal user'+ ((x.client_users||0)===1?'':'s')+ '
'+ ''+esc(trial.text)+''+ '
'+ '
'+ ''+ ''+ ''+ '
'+ '
'+ '
'; }).join('') :'
No client workspaces yet.
')+ '
'+ '
'; createClient.onclick=async()=>{ clientMsg.innerHTML=''; createClient.disabled=true; const oldText=createClient.textContent; createClient.textContent='Creating workspace…'; try{ const r=await A('/admin/clients',{ method:'POST', body:JSON.stringify({ company:co.value, name:nm.value, email:ce.value, billing:{ description:newBillDesc.value, amount:Number(newBillAmount.value), paymentMethod:newBillMethod.value, recurrence:newBillRec.value, dueDate:newBillDue.value, trialWeeks:Number(newTrial.value) } }) }); WS=(await A('/workspaces')).workspaces; W=r.orgId; const contractFile=newContractFile?.files?.[0]||null; if(contractFile){ if(contractFile.type&&contractFile.type!=='application/pdf'&&!/\.pdf$/i.test(contractFile.name))throw Error('The client agreement must be a PDF file.'); clientMsg.innerHTML='

Workspace created. Securely uploading the client agreement…

'; const contractData=await fileToBase64(contractFile); await A('/contracts',{method:'POST',body:JSON.stringify({title:contractFile.name.replace(/\.pdf$/i,''),name:contractFile.name,data:contractData})}); } await refreshData({force:true}); tab=contractFile?'contracts':'billing'; render(); }catch(x){ clientMsg.innerHTML='

'+esc(x.message)+'

'; }finally{ createClient.disabled=false; createClient.textContent=oldText; } }; document.querySelectorAll('.openWs').forEach(b=>b.onclick=async()=>{ W=b.dataset.id; await refreshData({force:true}); tab=normalizeSection(new URLSearchParams(location.search).get('section'))==='pos'&&defaultNavigation().includes('pos')?'pos':'dashboard'; render(); }); document.querySelectorAll('.editWs').forEach(b=>b.onclick=async()=>{ const c=clients.find(x=>x.id===b.dataset.id); const box=document.getElementById('clientEdit-'+b.dataset.id); box.innerHTML='

Loading client subscription…

'; try{ const B=await A( '/billing?org='+encodeURIComponent(c.id) ); const invoices=B.invoices||[]; const plan=invoices.find(x=> !x.parent_invoice_id&& x.status!=='cancelled'&& x.recurrence!=='one_time' )||invoices.find(x=> !x.parent_invoice_id&& x.status!=='cancelled' )||null; const trialText=B.billing?.trialActive ?'Current trial ends '+displayDate(B.billing.trialEndsAt) :(B.billing?.trialEndsAt ?'Trial ended '+displayDate(B.billing.trialEndsAt) :'No active trial'); box.innerHTML= '
'+ '
Client details
'+ ''+ ''+ ''+ ''+ ''+ ''+ '
Subscription
'+ '

Trial status: '+esc(trialText)+'

'+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ '
'+ ''+ ''+ '
'+ '
'+ '
'; box.querySelector('.cancelClient').onclick=()=>box.innerHTML=''; box.querySelector('.saveClient').onclick=async()=>{ const msg=box.querySelector('.clientEditMsg'); const saveButton=box.querySelector('.saveClient'); msg.innerHTML=''; saveButton.disabled=true; const originalLabel=saveButton.textContent; saveButton.textContent='Saving changes…'; const trialWeeks=Number(box.querySelector('.ebt').value); const amount=Number(box.querySelector('.eba').value); if(!(amount>0)&&trialWeeks<0){ msg.innerHTML='

Changes were not saved: enter a valid subscription amount, or choose a trial change.

'; saveButton.disabled=false; saveButton.textContent=originalLabel; return; } try{ await A( '/admin/clients?org='+encodeURIComponent(c.id), { method:'PATCH', body:JSON.stringify({ company:box.querySelector('.eco').value, name:box.querySelector('.enm').value, email:box.querySelector('.eem').value }) } ); if(trialWeeks>=0){ await A( '/billing?action=trial&org='+encodeURIComponent(c.id), { method:'POST', body:JSON.stringify({weeks:trialWeeks}) } ); } if(amount>0){ await A( '/billing?action=update-subscription&org='+encodeURIComponent(c.id), { method:'POST', body:JSON.stringify({ description:box.querySelector('.ebd').value, amount, paymentMethod:box.querySelector('.ebm').value, recurrence:box.querySelector('.ebr').value, dueDate:box.querySelector('.ebdte').value, trialWeeks:-1 }) } ); } WS=(await A('/workspaces')).workspaces; const savedAt=new Intl.DateTimeFormat(undefined,{hour:'numeric',minute:'2-digit'}).format(new Date()); clientWorkspaceNotice=(box.querySelector('.eco').value.trim()||c.name)+' was updated at '+savedAt+'. Client details, subscription terms and the selected trial setting are now current.'; clientsView(); }catch(x){ msg.innerHTML='

Changes were not saved: '+esc(x.message)+'

'; saveButton.disabled=false; saveButton.textContent=originalLabel; } }; }catch(x){ box.innerHTML='

'+esc(x.message)+'

'; } }); document.querySelectorAll('.deleteWs').forEach(b=>b.onclick=async()=>{ if(!confirm( 'Permanently delete '+b.dataset.name+ ' and its entire client workspace?' ))return; try{ await A('/admin/client-delete',{ method:'POST', body:JSON.stringify({orgId:b.dataset.id}) }); WS=(await A('/workspaces')).workspaces; if(W===b.dataset.id){ W=U.orgId; } await refreshData({force:true}); clientsView(); }catch(x){ alert(x.message); } }); } function requestsView(){ const items=[...D.requests].sort((a,b)=>new Date(b.created_at)-new Date(a.created_at)); v.innerHTML='

Submit an HR request

Submitted requests

'+(items.length?items.map(x=>'
'+esc(x.service)+'
'+esc(x.status)+''+fmt(x.created_at)+'
'+(x.details?'
'+esc(x.details)+'
':'')+'
').join(''):'
No requests have been submitted yet.
')+'
'; markPeriodList('requests',v.querySelector('.two .card:last-child'),v.querySelectorAll('.two .card:last-child > .row'),items); addReq.onclick=async()=>{reqMsg.innerHTML='';try{await A('/requests',{method:'POST',body:JSON.stringify({service:svc.value,priority:'Medium',details:det.value})});await refreshData();requestsView()}catch(x){reqMsg.innerHTML='

'+esc(x.message)+'

'}}; document.querySelectorAll('.reqEdit').forEach(b=>b.onclick=()=>{let r=D.requests.find(x=>x.id===b.dataset.id),box=document.getElementById('reqEdit-'+b.dataset.id);box.innerHTML='
';box.querySelector('.cancelReq').onclick=()=>box.innerHTML='';box.querySelector('.saveReq').onclick=async()=>{try{await A('/requests?id='+encodeURIComponent(r.id),{method:'PATCH',body:JSON.stringify({service:box.querySelector('.rs').value,priority:r.priority||'Medium',details:box.querySelector('.rd').value})});await refreshData();requestsView()}catch(x){alert(x.message)}}}); document.querySelectorAll('.reqDelete').forEach(b=>b.onclick=async()=>{if(!confirm('Delete this submitted request? This cannot be undone.'))return;try{await A('/requests?id='+encodeURIComponent(b.dataset.id),{method:'DELETE'});await refreshData();requestsView()}catch(x){alert(x.message)}}) } function candidatesView(){ const resumeFor=id=>(D.documents||[]).find(d=>d.candidate_id===id); v.innerHTML='

Add candidate

Track recruitment activity from application through hiring. You can attach a CV or resume when the candidate is created.

PDF, DOC or DOCX. The file is malware-scanned and stored securely.

Pipeline

'+(D.candidates.length?D.candidates.map(x=>{let r=resumeFor(x.id);return '
'+esc(x.name)+'
'+esc(x.stage)+''+esc(x.role_title)+''+(r?''+esc(r.status==='Current'?'CV / resume attached':'CV / resume '+r.status)+'':'')+'
'+(r&&r.status==='Current'?'':'')+'
'}).join(''):'
No candidates in the pipeline yet.
')+'
'; markPeriodList('candidates',v.querySelector('.two .card:last-child'),v.querySelectorAll('.two .card:last-child > .row'),D.candidates); ca.onclick=async()=>{ candMsg.innerHTML=''; const f=cv.files[0]; if(f&&!/\.(pdf|doc|docx)$/i.test(f.name))return candMsg.innerHTML='

Please upload a PDF, DOC or DOCX resume.

'; try{ ca.disabled=true;ca.textContent=f?'Adding candidate & uploading CV…':'Adding candidate…'; const candidate=await A('/candidates',{method:'POST',body:JSON.stringify({name:cn.value,role:cr.value,stage:cs.value})}); if(f){ try{ const data=await fileToBase64(f); await A('/documents',{method:'POST',body:JSON.stringify({name:f.name,data,category:'Candidate CV / Resume',candidateId:candidate.id})}); }catch(uploadError){ await refreshData(); candMsg.innerHTML='

Candidate was added, but the CV upload failed: '+esc(uploadError.message)+'

'; ca.disabled=false;ca.textContent='Add candidate'; return; } } await refreshData();candidatesView(); }catch(x){ candMsg.innerHTML='

'+esc(x.message)+'

'; ca.disabled=false;ca.textContent='Add candidate'; } }; document.querySelectorAll('.candResume').forEach(b=>b.onclick=async()=>{try{let r=await fetch(apiUrl('/documents/file?id='+encodeURIComponent(b.dataset.id)),{credentials:'same-origin'});if(!r.ok)throw Error((await r.json().catch(()=>({}))).error||r.status);let blob=await r.blob(),url=URL.createObjectURL(blob),overlay=document.createElement('div');overlay.className='previewOverlay';overlay.innerHTML='
'+esc(b.dataset.name)+'
';document.body.appendChild(overlay);let close=()=>{overlay.remove();URL.revokeObjectURL(url)};overlay.querySelector('.closeCandidateResume').onclick=close;overlay.onclick=e=>{if(e.target===overlay)close()}}catch(x){alert('Unable to view CV / resume: '+x.message)}}); document.querySelectorAll('.cedit').forEach(b=>b.onclick=()=>{let c=D.candidates.find(x=>x.id===b.dataset.id),box=document.getElementById('edit-'+b.dataset.id);box.innerHTML='
';box.querySelector('.cancelEdit').onclick=()=>box.innerHTML='';box.querySelector('.saveEdit').onclick=async()=>{try{await A('/candidates?id='+encodeURIComponent(c.id),{method:'PATCH',body:JSON.stringify({name:box.querySelector('.en').value,role:box.querySelector('.er').value,stage:box.querySelector('.es').value})});await refreshData();candidatesView()}catch(x){alert(x.message)}}}); document.querySelectorAll('.cdelete').forEach(b=>b.onclick=async()=>{if(!confirm('Delete '+b.dataset.name+' from the candidate pipeline?'))return;try{await A('/candidates?id='+encodeURIComponent(b.dataset.id),{method:'DELETE'});await refreshData();candidatesView()}catch(x){alert(x.message)}}) } function contractsView(){ markAlertsRead('contracts'); const contracts=D.contracts||[]; const recipients=D.contractRecipients||[]; const locked=U.role==='client'&&D?.billingLocked; const rows=contracts.map(c=>{ const status=String(c.status||'pending').toLowerCase(); const signed=status==='signed'; return '
'+ '
'+ '
'+esc(c.title||c.original_name||'Client agreement')+''+ ''+(signed?'✓ Signed':'Awaiting signature')+''+ '
'+ '
'+ 'Issued '+fmt(c.created_at)+''+ (signed?'Signed '+fmt(c.signed_at)+'':'')+ (signed&&c.signer_name?'Signed by '+esc(c.signer_name)+'':'')+ (c.recipient_name?'Assigned to '+esc(c.recipient_name)+'':'')+ (c.created_by_name?'Sent by '+esc(c.created_by_name)+'':'')+ '
'+ '
'+ '
'+ ''+ (signed?'':'')+ (D.access?.contractsManage&&!signed&&(U.role==='admin'||String(c.created_by)===String(U.id))?'':'')+ '
'+ '
'; }).join(''); v.innerHTML= '
'+ '

Client Agreements

'+ '

'+(locked ?'Your agreement remains available for review and electronic signature even while portal billing access is restricted.' :'Review agreements, complete electronic signatures, and download the completed signed PDF directly from your secure workspace.')+'

'+ '
'+ '
'+ '
'+ '

Contracts & Signatures

Electronic signing records include the signer identity, timestamp, IP address, document fingerprint and consent record.

'+ (D.access?.contractsManage ?'
Drop a PDF here orNo PDF selected
' :'')+ '
'+ '
'+ (rows||'
No client agreement has been uploaded yet.
')+ '
'; if(D.access?.contractsManage&&window.contractUpload){ let selectedContractFile=null; const selectContractFile=f=>{ selectedContractFile=f||null; contractMsg.innerHTML=''; if(!f){contractFileName.textContent='No PDF selected';contractUpload.disabled=true;return} const valid=(!f.type||f.type==='application/pdf')&&/\.pdf$/i.test(f.name); contractFileName.textContent=f.name; contractUpload.disabled=!valid||!contractRecipient.value; if(!valid)contractMsg.innerHTML='

Please choose a PDF file.

'; }; contractChoose.onclick=()=>contractFile.click(); contractFile.onchange=()=>selectContractFile(contractFile.files?.[0]); contractRecipient.onchange=()=>{contractUpload.disabled=!selectedContractFile||!contractRecipient.value}; ['dragenter','dragover'].forEach(type=>contractDropZone.addEventListener(type,e=>{e.preventDefault();e.stopPropagation();contractDropZone.classList.add('isDragOver')})); ['dragleave','drop'].forEach(type=>contractDropZone.addEventListener(type,e=>{e.preventDefault();e.stopPropagation();contractDropZone.classList.remove('isDragOver')})); contractDropZone.addEventListener('drop',e=>selectContractFile(e.dataTransfer?.files?.[0])); contractUpload.onclick=async()=>{ const f=selectedContractFile||contractFile.files?.[0]; if(!f){contractMsg.innerHTML='

Choose a PDF file before uploading.

';return} if(!contractRecipient.value){contractMsg.innerHTML='

Choose the teammate who must review and sign this contract.

';return} if(f.type&&f.type!=='application/pdf'&&!/\.pdf$/i.test(f.name)){ contractMsg.innerHTML='

Contracts must be uploaded as PDF files.

'; return; } contractUpload.disabled=true; const old=contractUpload.textContent; contractUpload.textContent='Uploading…'; try{ const data=await fileToBase64(f); await A('/contracts',{method:'POST',body:JSON.stringify({ title:f.name.replace(/\.pdf$/i,''), name:f.name, data, recipientUserId:contractRecipient.value })}); await refreshData({force:true}); contractsView(); }catch(e){ contractMsg.innerHTML='

'+esc(e.message)+'

'; contractUpload.disabled=false; contractUpload.textContent=old; } }; } document.querySelectorAll('.contractReview').forEach(b=>b.onclick=()=>openContractSigner(b.dataset.id,b.dataset.name)); document.querySelectorAll('.contractDownload').forEach(b=>b.onclick=()=>downloadPortalFile('/contracts/file?id='+encodeURIComponent(b.dataset.id)+'&signed=1&download=1',b.dataset.name,b)); document.querySelectorAll('.contractVoid').forEach(b=>b.onclick=async()=>{ if(!confirm('Void this unsigned agreement?'))return; try{ await A('/contracts?id='+encodeURIComponent(b.dataset.id),{method:'DELETE'}); await refreshData({force:true}); contractsView(); }catch(e){alert(e.message)} }); } // Render authenticated PDF bytes locally; never send agreements to a document service. let contractPdfLibrary; function renderContractPdf(overlay,blob){ let disposed=false,documentTask,documentPdf,renderTask,pageNumber=1,zoom=1,busy=false; const status=overlay.querySelector('.pdfPageStatus'),pages=overlay.querySelector('.contractPdfPages'); const previous=overlay.querySelector('.pdfPrevious'),next=overlay.querySelector('.pdfNext'),zoomButton=overlay.querySelector('.pdfZoom'); function controls(){previous.disabled=busy||!documentPdf||pageNumber<=1;next.disabled=busy||!documentPdf||pageNumber>=documentPdf.numPages;zoomButton.disabled=busy||!documentPdf} async function draw(){ busy=true;controls(); try{ const page=await documentPdf.getPage(pageNumber);if(disposed)return; const base=page.getViewport({scale:1}); const width=Math.max(200,pages.clientWidth-24)*zoom; const viewport=page.getViewport({scale:width/base.width}); const scale=Math.min(window.devicePixelRatio||1,2,4096/Math.max(viewport.width,viewport.height)); const canvas=document.createElement('canvas');canvas.setAttribute('aria-label','Agreement page '+pageNumber); canvas.width=Math.ceil(viewport.width*scale);canvas.height=Math.ceil(viewport.height*scale); canvas.style.width=viewport.width+'px';canvas.style.height=viewport.height+'px'; pages.replaceChildren(canvas); renderTask=page.render({canvasContext:canvas.getContext('2d'),viewport,transform:[scale,0,0,scale,0,0]}); await renderTask.promise;if(disposed)return; const text=await page.getTextContent();if(disposed)return; const details=document.createElement('details'),summary=document.createElement('summary'),body=document.createElement('div'); summary.textContent='Read page text';body.className='contractPdfText';body.textContent=text.items.map(item=>item.str+(item.hasEOL?'\n':' ')).join('');details.append(summary,body);pages.append(details); status.textContent='Page '+pageNumber+' of '+documentPdf.numPages;pages.scrollTop=0;pages.scrollLeft=0; }catch(error){if(!disposed){status.textContent='Unable to display page '+pageNumber;pages.textContent='Please use Open PDF to review the agreement. '+error.message}} finally{busy=false;if(!disposed)controls()} } previous.onclick=()=>{if(!busy&&pageNumber>1){pageNumber--;draw()}}; next.onclick=()=>{if(!busy&&documentPdf&&pageNumber{if(busy||!documentPdf)return;zoom=zoom===1?1.7:1;zoomButton.textContent=zoom===1?'Zoom in':'Fit width';draw()}; controls(); (async()=>{ try{ if(!contractPdfLibrary)contractPdfLibrary=import('https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.624/build/pdf.mjs').catch(e=>{contractPdfLibrary=null;throw e}); const library=await contractPdfLibrary;if(disposed)return; library.GlobalWorkerOptions.workerSrc='https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.624/build/pdf.worker.mjs'; documentTask=library.getDocument({data:new Uint8Array(await blob.arrayBuffer()),isEvalSupported:false,cMapUrl:'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.624/cmaps/',cMapPacked:true,standardFontDataUrl:'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.624/standard_fonts/',wasmUrl:'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.4.624/wasm/'}); documentPdf=await documentTask.promise;if(disposed){documentPdf.destroy();return}await draw(); }catch(error){if(!disposed){status.textContent='PDF viewer unavailable';pages.textContent='Use Open PDF to read the full agreement, then return here to sign. '+error.message}} })(); return ()=>{disposed=true;renderTask?.cancel();documentTask?.destroy()}; } async function openContractSigner(contractId,contractName){ const c=(D.contracts||[]).find(x=>String(x.id)===String(contractId))||{}; const alreadySigned=String(c.status||'').toLowerCase()==='signed'; const canSign=c.can_sign===true; let response,blob,url; try{ response=await fetch(apiUrl('/contracts/file?id='+encodeURIComponent(contractId)+(alreadySigned?'&signed=1':'')),{credentials:'same-origin',cache:'no-store'}); if(!response.ok){ const d=await response.json().catch(()=>({})); throw Error(d.error||('Unable to load agreement ('+response.status+')')); } blob=await response.blob(); url=URL.createObjectURL(blob); }catch(e){ alert(e.message); return; } const overlay=document.createElement('div'); overlay.className='contractSignOverlay'; overlay.innerHTML= '
'+ '
Loading agreement…Open PDF
'+ '
'+ '
WellSpoke HR · Secure Electronic Signature
'+ '

'+esc(contractName||'Client agreement')+'

'+ '

'+(alreadySigned?'Review the completed signed agreement and signature certificate using Previous and Next.':'Review every page using Previous and Next, then complete the signature form below or beside the document. Your signature is associated with this exact agreement and an electronic signing audit record.')+'

'+ (alreadySigned ?'
Completed
Signed by '+esc(c.signer_name||'Client')+'
'+fmt(c.signed_at)+'

You are viewing the completed signed PDF, including its signature certificate. A copy can also be downloaded from the Contracts page.
' :!canSign ?'
Read-only review
This agreement is assigned to '+esc(c.recipient_name||'another teammate')+'. Only that teammate can sign it.
' :'
'+ ''+ ''+ '
Use your mouse, trackpad, finger or stylus.
'+ ''+ '
WellSpoke HR records the authenticated portal user, signing time, IP address, browser information and cryptographic document fingerprints as part of the audit trail.
'+ '
'+ '
'+ '
')+ '
'+ '
'; document.body.appendChild(overlay); const previousFocus=document.activeElement; overlay.setAttribute('role','dialog');overlay.setAttribute('aria-modal','true');overlay.setAttribute('aria-label','Review and sign agreement'); const previousOverflow=document.body.style.overflow;document.body.style.overflow='hidden'; let disposePdf=()=>{}; const close=()=>{disposePdf();overlay.remove();document.body.style.overflow=previousOverflow;URL.revokeObjectURL(url);previousFocus?.focus()}; overlay.querySelector('.pdfClose').onclick=close; overlay.querySelector('.pdfClose').focus(); overlay.addEventListener('keydown',e=>{if(e.key==='Escape')close();if(e.key==='Tab'){const items=[...overlay.querySelectorAll('button:not(:disabled),a,input,summary')];const first=items[0],last=items[items.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}}); disposePdf=renderContractPdf(overlay,blob); overlay.querySelector('#contractClose').onclick=close; overlay.onclick=e=>{if(e.target===overlay)close()}; if(alreadySigned||!canSign)return; const canvas=overlay.querySelector('#signatureCanvas'); const ctx=canvas.getContext('2d'); ctx.lineWidth=3; ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#071A33'; let drawing=false,hasInk=false; const point=e=>{ const r=canvas.getBoundingClientRect(); return { x:(e.clientX-r.left)*(canvas.width/r.width), y:(e.clientY-r.top)*(canvas.height/r.height) }; }; canvas.addEventListener('pointerdown',e=>{ if(e.pointerType==='mouse'&&e.button!==0)return; e.preventDefault();drawing=true;canvas.setPointerCapture(e.pointerId); const p=point(e);ctx.beginPath();ctx.moveTo(p.x,p.y); }); canvas.addEventListener('pointermove',e=>{ if(!drawing)return; const p=point(e);ctx.lineTo(p.x,p.y);ctx.stroke();hasInk=true; }); const end=e=>{drawing=false;try{canvas.releasePointerCapture(e.pointerId)}catch(_){}}; canvas.addEventListener('pointerup',end); canvas.addEventListener('pointercancel',end); overlay.querySelector('#clearSignature').onclick=()=>{ ctx.clearRect(0,0,canvas.width,canvas.height); hasInk=false; }; overlay.querySelector('#completeSignature').onclick=async()=>{ const btn=overlay.querySelector('#completeSignature'); const msg=overlay.querySelector('#contractSignMsg'); const signerName=overlay.querySelector('#contractSignerName').value.trim(); const consent=overlay.querySelector('#contractConsent').checked; if(!signerName){msg.innerHTML='

Enter your legal name.

';return} if(!hasInk){msg.innerHTML='

Please provide your signature.

';return} if(!consent){msg.innerHTML='

You must confirm that you reviewed and agree to the agreement.

';return} if(!window.PDFLib){msg.innerHTML='

The PDF signing component could not load. Refresh the page and try again.

';return} btn.disabled=true; btn.textContent='Completing agreement…'; msg.innerHTML='

Creating your completed signed PDF and secure audit record…

'; try{ const originalBytes=new Uint8Array(await blob.arrayBuffer()); const {PDFDocument,StandardFonts,rgb}=window.PDFLib; const pdf=await PDFDocument.load(originalBytes); const page=pdf.addPage([612,792]); const font=await pdf.embedFont(StandardFonts.Helvetica); const bold=await pdf.embedFont(StandardFonts.HelveticaBold); const navy=rgb(7/255,26/255,51/255); const blue=rgb(80/255,132/255,165/255); const gray=rgb(92/255,112/255,126/255); page.drawText('WELLSPOKE HR',{x:52,y:738,size:10,font:bold,color:blue}); page.drawText('Electronic Signature Certificate',{x:52,y:700,size:22,font:bold,color:navy}); page.drawText('This page forms part of the completed agreement.',{x:52,y:678,size:10,font,color:gray}); page.drawText('Agreement',{x:52,y:630,size:9,font:bold,color:blue}); page.drawText(String(contractName||'Client agreement').slice(0,85),{x:52,y:610,size:12,font,color:navy}); page.drawText('Signer',{x:52,y:568,size:9,font:bold,color:blue}); page.drawText(signerName.slice(0,80),{x:52,y:548,size:12,font,color:navy}); page.drawText('Portal account',{x:52,y:516,size:9,font:bold,color:blue}); page.drawText(String(U.email||'').slice(0,90),{x:52,y:496,size:11,font,color:navy}); page.drawText('Signed',{x:52,y:464,size:9,font:bold,color:blue}); page.drawText(new Date().toLocaleString(),{x:52,y:444,size:11,font,color:navy}); page.drawText('Electronic signature',{x:52,y:394,size:9,font:bold,color:blue}); const sigPng=await pdf.embedPng(canvas.toDataURL('image/png')); const dims=sigPng.scale(0.45); const maxW=300,maxH=95; const scale=Math.min(1,maxW/dims.width,maxH/dims.height); page.drawImage(sigPng,{x:52,y:285,width:dims.width*scale,height:dims.height*scale}); page.drawLine({start:{x:52,y:278},end:{x:360,y:278},thickness:1,color:gray}); page.drawText(signerName.slice(0,80),{x:52,y:260,size:10,font,color:navy}); page.drawText('Consent',{x:52,y:220,size:9,font:bold,color:blue}); const consentLines=[ 'I reviewed the agreement and intended my electronic signature to be associated', 'with this document. I agreed to be bound by the agreement.' ]; consentLines.forEach((line,i)=>page.drawText(line,{x:52,y:202-i*15,size:9,font,color:gray})); page.drawText('A separate secure audit record is retained by WellSpoke HR.',{x:52,y:140,size:8,font,color:gray}); page.drawText('Generated through the WellSpoke HR Client Portal',{x:52,y:108,size:8,font:bold,color:blue}); const signedBytes=await pdf.save(); let binary=''; const chunk=0x8000; for(let i=0;i'; btn.disabled=false; btn.textContent='Sign & Complete Agreement'; } }; } function documentsView(){ const allDocs=(D.documents||[]).filter(x=>!x.candidate_id&&x.category!=='Policy document'); const folders=D.documentFolders||[]; if(documentFolder!=='all'&&documentFolder!=='unfiled'&&!folders.some(f=>f.id===documentFolder)){ documentFolder='all'; } const countFor=id=>{ if(id==='all')return allDocs.length; if(id==='unfiled')return allDocs.filter(d=>!d.folder_id).length; return allDocs.filter(d=>d.folder_id===id).length; }; const visibleDocs= documentFolder==='all' ?allDocs :documentFolder==='unfiled' ?allDocs.filter(d=>!d.folder_id) :allDocs.filter(d=>d.folder_id===documentFolder); const folderName= documentFolder==='all' ?'All documents' :documentFolder==='unfiled' ?'Unfiled' :(folders.find(f=>f.id===documentFolder)?.name||'Documents'); const folderOptions= ''+ folders.map(f=>'').join(''); const docs=visibleDocs.map(x=> '
'+ '
'+ ''+esc(x.name)+''+ (x.status!=='Current'?' '+esc(x.status)+'':'')+ '
'+ ''+Math.max(1,Math.round(Number(x.size||0)/1024))+' KB'+ ''+fmt(x.created_at)+''+ ''+esc(folders.find(f=>f.id===x.folder_id)?.name||'Unfiled')+''+ '
'+ '
'+ (x.status==='Current' ?'
'+ ''+ ''+ ''+ ''+ '
' :'')+ '
' ).join(''); const folderList= ''+ ''+ folders.map(f=> '
'+ ''+ ''+ '
' ).join(''); v.innerHTML= '
'+ ''+ '
'+ '
'+ '

Secure documents

Organize company documents into folders. Candidate CVs and resumes remain private to the Candidates section and do not appear here.

'+ ''+esc(folderName)+''+ '
'+ '
'+ (docs||'
No documents in this folder yet.
')+ '
'+ '
'+ '
Drag and drop a document hereNo document selected
'+ '
'+ ''+ ''+ ''+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'; markPeriodList('documents',v.querySelector('.folderShell > .card'),v.querySelectorAll('.folderShell > .card > .docrow'),visibleDocs); document.querySelectorAll('.folderButton').forEach(b=>b.onclick=()=>{ documentFolder=b.dataset.folder; documentsView(); }); createFolder.onclick=async()=>{ const name=newFolderName.value.trim(); if(!name)return; folderMsg.innerHTML=''; try{ const r=await A('/document-folders',{method:'POST',body:JSON.stringify({name})}); await refreshData(); documentFolder=r.id; documentsView(); }catch(x){ folderMsg.innerHTML='

'+esc(x.message)+'

'; } }; document.querySelectorAll('.folderDelete').forEach(b=>b.onclick=async()=>{ if(!confirm('Delete the folder "'+b.dataset.name+'"? Documents inside it will be moved to Unfiled.'))return; try{ await A('/document-folders/'+encodeURIComponent(b.dataset.id),{method:'DELETE'}); if(documentFolder===b.dataset.id)documentFolder='unfiled'; await refreshData(); documentsView(); }catch(x){ alert('Unable to delete folder: '+x.message); } }); document.querySelectorAll('.docview').forEach(b=>b.onclick=async()=>{ try{ let r=await fetch(apiUrl('/documents/file?id='+encodeURIComponent(b.dataset.id)),{credentials:'same-origin'}); if(!r.ok)throw Error((await r.json().catch(()=>({}))).error||r.status); let blob=await r.blob(),url=URL.createObjectURL(blob),overlay=document.createElement('div'); overlay.className='previewOverlay'; overlay.innerHTML='
'+esc(b.dataset.name)+'
'; document.body.appendChild(overlay); let close=()=>{overlay.remove();URL.revokeObjectURL(url)}; overlay.querySelector('#closePreview').onclick=close; overlay.onclick=e=>{if(e.target===overlay)close()}; }catch(x){ alert('Unable to view document: '+x.message); } }); document.querySelectorAll('.docdownload').forEach(b=>b.onclick=async()=>{ try{ let r=await fetch(apiUrl('/documents/file?id='+encodeURIComponent(b.dataset.id)+'&download=1'),{credentials:'same-origin'}); if(!r.ok)throw Error((await r.json().catch(()=>({}))).error||r.status); let blob=await r.blob(),url=URL.createObjectURL(blob),a=document.createElement('a'); a.href=url;a.download=b.dataset.name||'document';document.body.appendChild(a);a.click();a.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000); }catch(x){ alert('Unable to download document: '+x.message); } }); document.querySelectorAll('.docmove').forEach(s=>s.onchange=async()=>{ try{ await A('/documents/'+encodeURIComponent(s.dataset.id)+'/folder',{ method:'PATCH', body:JSON.stringify({folderId:s.value||null}) }); await refreshData(); documentsView(); }catch(x){ alert('Unable to move document: '+x.message); } }); document.querySelectorAll('.docdelete').forEach(b=>b.onclick=async()=>{ if(!confirm('Permanently delete '+b.dataset.name+'?'))return; try{ await A('/documents/'+encodeURIComponent(b.dataset.id),{method:'DELETE'}); await refreshData(); documentsView(); }catch(x){ alert('Unable to delete document: '+x.message); } }); let selectedDocumentFile=null; const selectDocumentFile=f=>{ selectedDocumentFile=f||null; documentFileName.textContent=f?f.name:'No document selected'; ue.textContent=''; }; file.onchange=()=>selectDocumentFile(file.files?.[0]); ['dragenter','dragover'].forEach(type=>documentDropZone.addEventListener(type,e=>{e.preventDefault();e.stopPropagation();documentDropZone.classList.add('isDragOver')})); ['dragleave','drop'].forEach(type=>documentDropZone.addEventListener(type,e=>{e.preventDefault();e.stopPropagation();documentDropZone.classList.remove('isDragOver')})); documentDropZone.addEventListener('drop',e=>selectDocumentFile(e.dataTransfer?.files?.[0])); up.onclick=async()=>{ let f=selectedDocumentFile||file.files?.[0]; if(!f)return; ue.textContent=''; uploadMsg.innerHTML='

Scanning and securely storing '+esc(f.name)+'…

'; try{ const data=await fileToBase64(f); await A('/documents',{ method:'POST', body:JSON.stringify({ name:f.name, data, folderId:uploadFolder.value||null }) }); await refreshData(); documentsView(); }catch(x){ uploadMsg.innerHTML=''; ue.textContent=x.message; } }; } let messageConversation='advisor'; function messagesView(){ const existingGroupPanel=v.querySelector('[data-group-root]'); if(messageConversation==='groups')messageConversation='advisor'; markAlertsRead('messages'); const all=D.messages||[],participants=D.messageParticipants||[],canManage=x=>U.role==='admin'||x.sender_id===U.id; const options=U.role==='admin'?(all.some(x=>x.channel_type==='advisor')?[{key:'advisor',label:'Legacy workspace messages'}]:[]):[{key:'advisor',label:'WellSpoke HR'}]; const canBroadcast=U.role==='client'&&(U.portalRole==='workspace_admin'||U.permissions?.full_workspace_admin); if(canBroadcast)options.push({key:'team',label:'Entire team announcement'}); participants.forEach(p=>options.push({key:'user:'+p.id,label:'Private message — '+p.name})); if(D.canMonitorMessages){ const seen=new Set(); all.filter(x=>x.channel_type==='direct'&&x.sender_id!==U.id&&x.recipient_id!==U.id).forEach(x=>{ const ids=[x.sender_id,x.recipient_id].sort(),key='monitor:'+ids.join(':'); if(!seen.has(key)){seen.add(key);options.push({key,label:'Monitor — '+[x.sender_name,x.recipient_name].sort().join(' ↔ ')})} }); } if(!options.some(x=>x.key===messageConversation))messageConversation=options[0]?.key||'advisor'; const selected=options.find(x=>x.key===messageConversation),monitoring=messageConversation.startsWith('monitor:'),broadcasting=messageConversation==='team',legacy=U.role==='admin'&&messageConversation==='advisor',readOnly=monitoring||legacy,targetId=messageConversation.startsWith('user:')?messageConversation.slice(5):null; const visible=all.filter(x=>{ if(messageConversation==='advisor')return x.channel_type==='advisor'; if(broadcasting)return false; if(targetId)return x.channel_type==='direct'&&((x.sender_id===U.id&&x.recipient_id===targetId)||(x.sender_id===targetId&&x.recipient_id===U.id)); if(monitoring){const ids=messageConversation.slice(8).split(':');return x.channel_type==='direct'&&ids.includes(x.sender_id)&&ids.includes(x.recipient_id)} return false; }); const announcements=Array.isArray(D.teamAnnouncements)?D.teamAnnouncements:[]; const optionHtml=options.map(x=>'').join(''); const rows=broadcasting?(announcements.length?announcements.map(x=>'
'+esc(x.body)+'
'+esc(x.sender_name)+' · sent to '+Number(x.recipient_count||0)+' teammate'+(Number(x.recipient_count||0)===1?'':'s')+' · '+fmt(x.created_at)+(x.updated_at&&new Date(x.updated_at).getTime()>new Date(x.created_at).getTime()+1000?' · edited':'')+'
').join(''):'
No team announcements yet. Your message will be delivered privately to every active teammate, and replies will remain in individual conversations.
'):visible.length?visible.map(x=>'
'+esc(x.body)+'
'+esc(x.sender_name)+(x.recipient_name?' to '+esc(x.recipient_name):'')+' · '+fmt(x.created_at)+(x.updated_at&&new Date(x.updated_at).getTime()>new Date(x.created_at).getTime()+1000?' · edited':'')+(monitoring?' · monitored':'')+'
'+(canManage(x)?'
':'')+'
').join(''):'
No messages in this conversation yet.
'; v.innerHTML='

Messages

Choose the WellSpoke HR conversation, privately message a teammate, or send an announcement to the entire team. Authorized monitoring never includes the WellSpoke owner or the workspace’s original client account.

'+(monitoring?'
Authorized message oversight
This conversation is read-only. Protected owner and original-client conversations are never included.
':legacy?'
Legacy workspace messages
Select an individual client or teammate to send a new private message.
':'')+'
'+rows+'
'+(readOnly?'':'
')+'
'; messageConversationSelect.onchange=()=>{messageConversation=messageConversationSelect.value;messagesView()}; const messagePeriodKey='messages-'+messageConversation; const newestMessage=(broadcasting?announcements:visible).map(x=>periodDate(x.created_at)).filter(Boolean).sort((a,b)=>b-a)[0]; if(newestMessage){ const previousType=periodListState[messagePeriodKey]?.type||'monthly'; periodListState[messagePeriodKey]={type:previousType,anchor:localDateString(newestMessage)}; } markPeriodList(messagePeriodKey,v.querySelector('.messages'),v.querySelectorAll('.messages > .bubbleWrap'),broadcasting?announcements:visible,x=>x?.created_at,{toolbarOutside:true,alwaysShow:true}); requestAnimationFrame(()=>requestAnimationFrame(()=>{ const messageScroller=document.querySelector('.messages'); if(messageScroller)messageScroller.scrollTop=messageScroller.scrollHeight; })); if(window.ms)ms.onclick=async()=>{ if(!mb.value.trim())return; try{ const result=await A('/messages',{method:'POST',body:JSON.stringify({body:mb.value,recipientId:targetId,broadcastTeam:broadcasting})}); if(broadcasting)alert('Message sent privately to '+result.recipientCount+' active teammate'+(result.recipientCount===1?'':'s')+'.'); await refreshData();messagesView(); }catch(x){msgErr.innerHTML='

'+esc(x.message)+'

'} }; document.querySelectorAll('.msgEdit').forEach(b=>b.onclick=()=>{ const msg=all.find(x=>x.id===b.dataset.id),box=document.getElementById('msgEdit-'+b.dataset.id); box.innerHTML='
'; box.querySelector('.cancelMessageEdit').onclick=()=>box.innerHTML=''; box.querySelector('.saveMessageEdit').onclick=async()=>{const body=box.querySelector('.messageEditText').value.trim();if(!body)return alert('Message cannot be empty.');try{await A('/messages?id='+encodeURIComponent(msg.id),{method:'PATCH',body:JSON.stringify({body})});await refreshData();messagesView()}catch(x){alert('Unable to edit message: '+x.message)}}; }); document.querySelectorAll('.msgDelete').forEach(b=>b.onclick=async()=>{if(!confirm('Delete this message?'))return;try{await A('/messages?id='+encodeURIComponent(b.dataset.id),{method:'DELETE'});await refreshData();messagesView()}catch(x){alert('Unable to delete message: '+x.message)}}); document.querySelectorAll('.announcementEdit').forEach(b=>b.onclick=()=>{const announcement=announcements.find(x=>x.id===b.dataset.id),box=document.getElementById('announcementEdit-'+b.dataset.id);box.innerHTML='
';box.querySelector('.cancelAnnouncementEdit').onclick=()=>box.innerHTML='';box.querySelector('.saveAnnouncementEdit').onclick=async()=>{const body=box.querySelector('.messageEditText').value.trim();if(!body)return alert('Announcement cannot be empty.');try{await A('/messages?broadcastId='+encodeURIComponent(announcement.id),{method:'PATCH',body:JSON.stringify({body})});await refreshData();messagesView()}catch(x){alert('Unable to edit announcement: '+x.message)}}}); document.querySelectorAll('.announcementDelete').forEach(b=>b.onclick=async()=>{if(!confirm('Delete this team announcement from every recipient conversation?'))return;try{await A('/messages?broadcastId='+encodeURIComponent(b.dataset.id),{method:'DELETE'});await refreshData();messagesView()}catch(x){alert('Unable to delete announcement: '+x.message)}}); const privatePanel=v.firstElementChild; privatePanel.classList.add('privateChatPanel'); privatePanel.querySelector('h2').textContent='Private chats & announcements'; const layout=document.createElement('div');layout.className='messageWorkspace'; v.append(layout);layout.append(privatePanel); const groupPanel=existingGroupPanel||document.createElement('div'); groupPanel.dataset.groupRoot='';layout.append(groupPanel); if(!existingGroupPanel)PortalMessageGroups.mount({root:groupPanel,api:A,userId:U.id}); } function teamView(){ const members=D.teamMembers||[]; const canAdminTeam=(U.role==='admin'||U.portalRole==='workspace_admin'||U.permissions?.full_workspace_admin); if(!canAdminTeam){ tab=normalizeSection(new URLSearchParams(location.search).get('section'))==='pos'&&defaultNavigation().includes('pos')?'pos':'dashboard'; render(); return; } v.innerHTML= '
'+ '
'+ '

Add team member

'+ '

Invite another person from your organization to this client portal. Workspace administrators can add, update, deactivate and manage other team members.

'+ '
'+ ''+ ''+ ''+ ''+ ''+ ''+ '
'+ '
'+ '
'+ '
'+ '

Portal team

'+ '

Manage who can access this workspace. Workspace administrators can manage team members, but cannot access other client workspaces.

'+ (members.length ?members.map(m=> '
'+ '
'+ ''+esc(m.name)+''+ '
'+ ''+esc(m.email)+''+ ''+(m.portal_role==='workspace_admin'?'Workspace administrator':'Team member')+''+ (m.active?'Active':'Inactive')+ (m.permissions?.monitor_team_messages?'Message oversight':'')+ (m.permissions?.manage_timeclock_settings?'Time Clock settings':'')+ '
'+ '
'+ '
'+ ''+ (m.id!==U.id?'':'')+ (m.id!==U.id ?'' :'')+ '
'+ '
'+ '
' ).join('') :'
No team members yet.
')+ '
'+ '
'; markPeriodList('team-members',v.querySelector('.teamGrid .card:last-child'),v.querySelectorAll('.teamGrid .card:last-child > .teamMember'),members,m=>m.created_at||m.updated_at); addTeamMember.onclick=async()=>{ teamMsg.innerHTML=''; try{ await A('/team-members',{ method:'POST', body:JSON.stringify({ name:teamName.value, email:teamEmail.value, department:teamDepartment.value, portalRole:teamRole.value }) }); await refreshData(); teamView(); }catch(x){ teamMsg.innerHTML='

'+esc(x.message)+'

'; } }; document.querySelectorAll('.teamPermissions').forEach(b=>b.onclick=()=>{ const member=members.find(m=>m.id===b.dataset.id),box=document.getElementById('teamEdit-'+b.dataset.id),permissions=member.permissions||{}; const permissionLabels={full_workspace_admin:'Full workspace administrator access',manage_group_chats:'Create group chats and manage topics and participants',manage_virtual_office_departments:'Create and manage private Virtual Office departments',manage_job_postings:'Create, edit, publish and close job postings',monitor_team_messages:'Monitor conversations between non-protected teammates',manage_timeclock_settings:'Manage Time Clock and IP-restriction settings',view_all_requests:'View and manage all HR requests',manage_candidates:'Add and manage candidates',view_all_documents:'View and manage documents uploaded by every teammate',view_all_leave:'View and manage all leave requests',view_all_incidents:'View and manage all incident reports',view_all_kpis:'View KPIs for all departments',manage_kpi_targets:'Create and manage KPI targets for team members',manage_tasks:'Assign and review team tasks',view_all_sales:'View all Point of Sale transactions',manage_billing:'View and manage workspace billing',manage_expenses:'Manage business expenses for the workspace',view_all_support:'View and manage all technical-support requests and portal suggestions'}; box.innerHTML='

Security access for '+esc(member.name)+'

Grant complete workspace-administrator access or select only the areas this team member should manage. WellSpoke owner access and other client workspaces remain unavailable. Owner and original-client communications can never be monitored.

'+Object.entries(permissionLabels).map(([key,label])=>'').join('')+'
'; box.querySelector('.selectAllPermissions').onclick=()=>box.querySelectorAll('.permissionToggle').forEach(x=>x.checked=true); box.querySelector('.clearAllPermissions').onclick=()=>box.querySelectorAll('.permissionToggle').forEach(x=>x.checked=false); box.querySelector('.cancelPermissions').onclick=()=>box.innerHTML=''; box.querySelector('.savePermissions').onclick=async()=>{try{const updated={};box.querySelectorAll('.permissionToggle').forEach(x=>updated[x.dataset.key]=x.checked);await A('/team-members/'+encodeURIComponent(member.id)+'/permissions',{method:'PATCH',body:JSON.stringify({permissions:updated})});await refreshData();teamView();alert('Security access saved successfully.')}catch(x){alert('Unable to save security access: '+x.message)}}; }); document.querySelectorAll('.teamEdit').forEach(b=>b.onclick=()=>{ const member=members.find(m=>m.id===b.dataset.id); const box=document.getElementById('teamEdit-'+b.dataset.id); box.innerHTML= '
'+ ''+ ''+ ''+ ''+ ''+ '
'+ '
'; box.querySelector('.cancelTeam').onclick=()=>box.innerHTML=''; box.querySelector('.saveTeam').onclick=async()=>{ try{ await A('/team-members/'+encodeURIComponent(member.id),{ method:'PATCH', body:JSON.stringify({ name:box.querySelector('.tn').value, email:box.querySelector('.te').value, department:box.querySelector('.td').value, portalRole:box.querySelector('.tr').value }) }); await refreshData(); teamView(); }catch(x){ alert('Unable to update team member: '+x.message); } }; }); document.querySelectorAll('.teamToggle').forEach(b=>b.onclick=async()=>{ const active=b.dataset.active==='1'; if(!confirm((active?'Deactivate':'Reactivate')+' this portal user?'))return; try{ await A('/team-members/'+encodeURIComponent(b.dataset.id)+'/active',{ method:'PATCH', body:JSON.stringify({active:!active}) }); await refreshData(); teamView(); }catch(x){ alert('Unable to update access: '+x.message); } }); } let workforceModule='leave'; const workforceConfig={ attendance:{label:'Attendance',fields:[['date','Date','date'],['name','Employee name','text'],['time_in','Time in','time'],['time_out','Time out','time'],['break_hours','Break hours','number'],['notes','Notes','textarea']]}, leave:{label:'Leave Requests',fields:[['full_name','Employee name','text'],['leave_type','Leave type','select:Vacation|Sick|Personal|Bereavement|Unpaid|Other'],['start_date','Start date','date'],['end_date','End date','date'],['reason','Reason','textarea'],['status','Status','select:Pending|Approved|Denied'],['administrator_note','Administrator note','textarea'],['administrator_note_visibility','Administrator note visibility','select:shared::Shared|confidential::Confidential']]}, incidents:{label:'Incidents',fields:[['reporter_name','Reporter','text'],['incident_date','Incident date','date'],['location','Location','text'],['incident_type','Type','select:Safety|Harassment|Property Damage|Theft|Conflict|Other'],['severity','Severity','select:Low|Medium|High|Critical'],['description','Description','textarea'],['witnesses','Witnesses','text'],['status','Status','select:Reported|Under Review|Resolved|Closed'],['administrator_note','Administrator note','textarea'],['administrator_note_visibility','Administrator note visibility','select:shared::Shared|confidential::Confidential']]}, policies:{label:'Policies',fields:[['section','Section','text'],['policy_area','Policy area','text'],['guidelines','Guidelines','textarea']]}, holidays:{label:'Holidays',fields:[['name','Holiday name','text'],['date','Date','date'],['is_statutory','Statutory holiday','checkbox']]}, projects:{label:'Project Management',fields:[]}, kpis:{label:'KPI Reporting',fields:[['department','Department','text'],['owner','Team member','member-select'],['objective','Objective','textarea'],['kpi','KPI','text'],['target','Target','text'],['frequency','Reporting frequency','select:daily::Daily|weekly::Weekly|biweekly::Biweekly|monthly::Monthly|quarterly::Quarterly|custom::Custom'],['actual','Actual result','text'],['status','Status','select:On Track|At Risk|Off Track'],['notes','Notes','textarea'],['administrator_note','Administrator note','textarea'],['administrator_note_visibility','Administrator note visibility','select:shared::Shared|confidential::Confidential']]}, tasks:{label:'Team Tasks',fields:[]}, contacts:{label:'Contacts',fields:[['name','Name','text'],['email','Email','email'],['phone','Phone','text'],['scope','Contact type','select:firm::Firm contact|confidential::Confidential contact'],['notes','Notes','textarea']]}, referrals:{label:'Referrals',fields:[['name','Candidate name','text'],['email','Email','email'],['phone','Phone','text'],['role_of_interest','Role of interest','text'],['referrer','Referrer','text']]} }; function accessibleWorkforceModules(){ const manager= U?.role==='admin'|| U?.portalRole==='workspace_admin'|| U?.permissions?.full_workspace_admin; if(manager){ return [ 'leave', 'incidents', 'policies', 'holidays', 'projects', 'contacts', 'referrals' ]; } return [ 'leave', 'incidents', 'policies', 'holidays', 'projects', 'contacts', 'referrals' ]; } function defaultWorkforceOrder(){ return accessibleWorkforceModules().filter(k=>moduleVisible(k==='projects'?'workforce_projects':'workforce_'+k)); } function currentWorkforceOrder(){ const allowed=defaultWorkforceOrder(); const saved=Array.isArray(U?.workforceOrder) ?U.workforceOrder :[]; const result=[]; for(const item of saved){ if( allowed.includes(item)&& workforceConfig[item]&& !result.includes(item) ){ result.push(item); } } for(const item of allowed){ if( workforceConfig[item]&& !result.includes(item) ){ result.push(item); } } return result; } function workforceModuleDescription(module){ const descriptions={ leave:'Submit, review and track employee leave requests.', incidents:'Record workplace incidents and follow them through resolution.', policies:'Maintain workplace policies, guidance and reference material.', holidays:'Manage statutory and company holidays.', projects:'Track tasks, KPI targets and results in one coordinated workspace.', kpis:'Set performance targets and submit KPI results.', tasks:'Assign work, collect submissions and complete reviews.', contacts:'Maintain shared firm contacts and access-restricted confidential contacts.', referrals:'Record and manage candidate referrals.' }; return descriptions[module]||'Manage workforce records.'; } function workforceModuleNavigation(wfOrder){ return '
'+wfOrder.map(module=>{ const cfg=workforceConfig[module],active=module===workforceModule; const count=(D.workforce||[]).filter(record=>record.module===module).length; return ''; }).join('')+'
'; } function openWorkforceCustomizer(){ let order=[...currentWorkforceOrder()]; const overlay=document.createElement('div'); overlay.className='wfOrderOverlay'; const draw=()=>{ overlay.innerHTML= '
'+ '
Workforce preferences
'+ '

Customize Workforce order

'+ '

Arrange Attendance, Leave Requests, Incidents and the other Workforce categories in the order that works best for you. This preference is saved to your portal account.

'+ '
'+ order.map((item,i)=> '
'+ '
⋮⋮
'+ '
'+esc(workforceConfig[item]?.label||item)+'
'+ '
'+ ''+ ''+ '
'+ '
' ).join('')+ '
'+ '
'+ '
'+ ''+ '
'+ ''+ ''+ '
'+ '
'+ '
'; overlay.querySelector('#cancelWfOrder').onclick=()=>overlay.remove(); overlay.querySelector('#resetWfOrder').onclick=()=>{ order=defaultWorkforceOrder(); draw(); }; overlay.querySelectorAll('.wfOrderUp').forEach(b=>b.onclick=()=>{ const i=Number(b.dataset.index); if(i<=0)return; [order[i-1],order[i]]=[order[i],order[i-1]]; draw(); }); overlay.querySelectorAll('.wfOrderDown').forEach(b=>b.onclick=()=>{ const i=Number(b.dataset.index); if(i>=order.length-1)return; [order[i],order[i+1]]=[order[i+1],order[i]]; draw(); }); let dragging=null; overlay.querySelectorAll('.wfOrderItem').forEach(item=>{ item.addEventListener('dragstart',()=>{ dragging=item.dataset.item; item.classList.add('dragging'); }); item.addEventListener('dragend',()=>{ dragging=null; overlay.querySelectorAll('.wfOrderItem').forEach(x=>{ x.classList.remove('dragging','dragOver'); }); }); item.addEventListener('dragover',e=>{ e.preventDefault(); if(dragging&&dragging!==item.dataset.item){ item.classList.add('dragOver'); } }); item.addEventListener('dragleave',()=>{ item.classList.remove('dragOver'); }); item.addEventListener('drop',e=>{ e.preventDefault(); item.classList.remove('dragOver'); if(!dragging||dragging===item.dataset.item)return; const from=order.indexOf(dragging); const to=order.indexOf(item.dataset.item); if(from<0||to<0)return; const [moved]=order.splice(from,1); order.splice(to,0,moved); draw(); }); }); overlay.querySelector('#saveWfOrder').onclick=async()=>{ const msg=overlay.querySelector('#wfOrderMsg'); msg.innerHTML='

Saving your Workforce order…

'; try{ const r=await A('/workforce-navigation',{ method:'PATCH', body:JSON.stringify({order}) }); U.workforceOrder=r.order; if(!r.order.includes(workforceModule)){ workforceModule=r.order[0]||'attendance'; } overlay.remove(); workforceView(); }catch(x){ msg.innerHTML='

'+esc(x.message)+'

'; } }; }; draw(); document.body.appendChild(overlay); overlay.addEventListener('click',e=>{ if(e.target===overlay)overlay.remove(); }); } function wfFieldHtml(f,val=''){ const [key,label,type]=f,wide=type==='textarea'?' wide':''; if(type==='textarea')return '
'; if(type==='checkbox')return '
'; if(type==='member-select'){ const members=[...(U?.role==='client'?[{id:U.id,name:U.name,active:true}]:[]),...(D?.teamMembers||[])].filter(m=>m.active!==false&&m.name),seen=new Set(),names=members.map(m=>String(m.name)).filter(name=>{const k=name.toLowerCase();if(seen.has(k))return false;seen.add(k);return true}); if(val&&!seen.has(String(val).toLowerCase()))names.push(String(val)); return '
'; } if(type.startsWith('select:')){let opts=type.slice(7).split('|');return '
'} const displayValue=type==='date'?dateValue(val):(val??''); return '
'; } function wfCollect(box){let data={};box.querySelectorAll('[data-wf]').forEach(el=>{data[el.dataset.wf]=el.type==='checkbox'?el.checked:(el.type==='number'&&el.value!==''?Number(el.value):el.value)});return data} function policyDocumentHtml(data,editable=false){ if(!data?.document_id)return editable?'No document attached':''; const ready=data.document_status==='Current'; return ''+esc(data.document_name||'Policy document')+''+ (ready?'':''+esc(data.document_status||'Processing')+'')+ (editable?'':''); } async function openPolicyDocument(button,download=false){ try{ const response=await fetch(apiUrl('/documents/file?id='+encodeURIComponent(button.dataset.id)+(download?'&download=1':'')),{credentials:'same-origin'}); if(!response.ok)throw Error((await response.json().catch(()=>({}))).error||response.status); const blob=await response.blob(),url=URL.createObjectURL(blob); if(download){const a=document.createElement('a');a.href=url;a.download=button.dataset.name||'policy-document';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);return} const overlay=document.createElement('div');overlay.className='previewOverlay';overlay.innerHTML='
'+esc(button.dataset.name||'Policy document')+'
';document.body.appendChild(overlay); const close=()=>{overlay.remove();URL.revokeObjectURL(url)};overlay.querySelector('.closePolicyDocument').onclick=close;overlay.onclick=e=>{if(e.target===overlay)close()}; }catch(e){alert('Unable to open policy document: '+e.message)} } function wfTitle(r){let d=r.data||{};return d.full_name||d.name||d.policy_area||d.department||d.reporter_name||d.objective||'Record'} function wfSummary(r){ let d=r.data||{},parts=[]; ['date','start_date','end_date','leave_type','incident_type','severity','status','role_of_interest','owner','target','actual'].forEach(k=>{ if(d[k]!==undefined&&d[k]!==''&&d[k]!==false){ parts.push( ['date','start_date','end_date'].includes(k) ?displayDate(d[k]) :String(d[k]) ); } }); return parts.filter(Boolean).slice(0,4).join(' · '); } function timeClockAdminPage(){ const canManage= U.role==='admin'|| U.portalRole==='workspace_admin'|| U.permissions?.full_workspace_admin|| U.permissions?.manage_timeclock_settings; if(!canManage){ tab=normalizeSection(new URLSearchParams(location.search).get('section'))==='pos'&&defaultNavigation().includes('pos')?'pos':'dashboard'; render(); return; } v.innerHTML= '
'+ '
Time Clock Administration
'+ '

Attendance, corrections & exports

'+ '

Manage time-clock settings, firm time zone, approved IP addresses, individual entries, corrections, deletions and payroll exports.

'+ '
'+ timeClockAdminHtml(); bindTimeClockAdmin(); } function taskAttachmentLinks(task){ const ids=Array.isArray(task.data?.attachment_ids)?task.data.attachment_ids:[]; const docs=(D.documents||[]).filter(d=>ids.includes(String(d.id))); return docs.length ?'
'+docs.map(d=>'').join('')+'
' :''; } function projectWorkspaceNavigation(){ return ''; } function bindProjectWorkspaceNavigation(){ document.querySelectorAll('[data-wfm]').forEach(button=>button.onclick=()=>{workforceModule=button.dataset.wfm;tab='workforce';render()}); } function projectManagementView(){ markAlertsRead('tasks'); const records=(D.workforce||[]).filter(x=>['tasks','kpis'].includes(x.module)); const canAdmin=U.role==='admin'||U.portalRole==='workspace_admin'||U.permissions?.full_workspace_admin; const canTasks=canAdmin||U.permissions?.manage_tasks; const canKpis=canAdmin||U.permissions?.manage_kpi_targets; const tasks=records.filter(x=>x.module==='tasks'),kpis=records.filter(x=>x.module==='kpis'); const today=new Date().toISOString().slice(0,10); const overdue=tasks.filter(x=>!['Approved','Pending review'].includes(x.data?.status)&&/^\d{4}-\d{2}-\d{2}$/.test(x.data?.due_date||'')&&x.data.due_datex.data?.status==='Pending review').length; const onTrack=kpis.filter(x=>x.data?.status==='On Track').length; const columns=[ {key:'planned',label:'Planned'}, {key:'active',label:'In progress'}, {key:'review',label:'Needs attention'}, {key:'complete',label:'Completed / on track'} ]; const stage=r=>{ const d=r.data||{}; if(r.module==='tasks'){ if(d.status==='Approved')return 'complete'; if(d.status==='Pending review'||d.status==='Changes requested'||(/^\d{4}-\d{2}-\d{2}$/.test(d.due_date||'')&&d.due_datevalue==='team'?'Entire team':value==='administrators'?'Administrators only':'Assigned teammate'; const card=r=>{ const d=r.data||{},isTask=r.module==='tasks',title=isTask?(d.title||'Task'):(d.objective||d.kpi||'KPI target'),owner=isTask?(d.assignee_name||'Unassigned'):(d.owner||'Unassigned'); const target=Number.parseFloat(d.target),actual=Number.parseFloat(d.actual),percent=Number.isFinite(target)&&target!==0&&Number.isFinite(actual)?Math.max(0,Math.min(100,Math.round(actual/target*100))):null; const canControl=isTask?canTasks:(canAdmin||(canKpis&&d.record_type==='target')); const visibility=d.tracker_visibility||'assigned'; return '
'+(isTask?'Task':(d.record_type==='report'?'KPI report':'KPI target'))+''+esc(d.status||'Active')+'

'+esc(title)+'

'+esc(owner)+''+(isTask&&d.due_date?'Due '+esc(displayDate(d.due_date))+'':'')+(!isTask&&d.frequency?''+esc(String(d.frequency).replace(/^./,x=>x.toUpperCase()))+'':'')+'
'+(!isTask?'
Target: '+esc(d.target||'—')+' · Actual: '+esc(d.actual||'—')+'
'+(percent!==null?'
'+percent+'% of target
':''):'')+(canControl?'
':'
Visibility: '+esc(visibilityLabel(visibility))+'
')+'
'; }; const board=columns.map(column=>{const items=records.filter(r=>stage(r)===column.key);return '
'+column.label+''+items.length+'
'+(items.length?items.map(card).join(''):'
No items
')+'
'}).join(''); v.innerHTML=projectWorkspaceNavigation()+'
People & operations

Project Management

Track team tasks, KPI targets and reported results in one coordinated workspace.

'+tasks.length+'Total tasks
'+awaiting+'Awaiting review
'+overdue+'Overdue tasks
'+onTrack+' / '+kpis.length+'KPIs on track

Work tracker

Progress is organized by current status. Visibility controls determine which teammates can see each item.

'+board+'
'; bindProjectWorkspaceNavigation(); document.querySelectorAll('.projectVisibilitySelect').forEach(select=>select.onchange=async()=>{const r=records.find(x=>x.id===select.dataset.id),previous=r.data?.tracker_visibility||'assigned';select.disabled=true;try{await A('/workforce/'+encodeURIComponent(r.id),{method:'PATCH',body:JSON.stringify({data:{...r.data,tracker_visibility:select.value}})});await refreshData();projectManagementView()}catch(e){select.value=previous;select.disabled=false;alert('Unable to update tracker visibility: '+e.message)}}); } function teamTasksView(wfOrder){ markAlertsRead('tasks'); const records=(D.workforce||[]).filter(x=>x.module==='tasks'); const canManage=U.role==='admin'||U.portalRole==='workspace_admin'||U.permissions?.full_workspace_admin||U.permissions?.manage_tasks; const members=(D.teamMembers||[]).filter(x=>x.active&&x.id!==U.id); const formHtml=canManage ?'

Assign a team task

Set the work, assignee and due date. The assignee can report completion and submit supporting documents for review.

' :''; const rows=records.length?records.map(r=>{ const d=r.data||{},assigned=String(d.assignee_id||'')===String(U.id),pending=d.status==='Pending review',overdue=!['Approved','Pending review'].includes(d.status)&&/^\d{4}-\d{2}-\d{2}$/.test(d.due_date||'')&&d.due_date
'+esc(overdue?'Overdue':(d.status||'Assigned'))+'Due '+esc(displayDate(d.due_date))+'
'+esc(d.title||'Task')+'
Assigned to '+esc(d.assignee_name||'Team member')+'
'+(d.description?'
'+esc(d.description)+'
':'')+(d.administrator_note?'
Administrator note'+esc(d.administrator_note_visibility==='confidential'?'Confidential':'Shared')+'
'+esc(d.administrator_note)+'
':'')+(overdue&&assigned?'
Completion is overdue.
Report completion now and explain why it was not reported by the due date.
':'')+(d.submission_notes?'
Completion report
'+esc(d.submission_notes)+'
':'')+(d.late_explanation?'
Late-completion explanation
'+esc(d.late_explanation)+'
':'')+(d.review_notes?'
Administrator review
'+esc(d.review_notes)+'
':'')+taskAttachmentLinks(r)+'
'+(assigned&&!['Approved','Pending review'].includes(d.status)?'':'')+(canManage&&pending?'':'')+(canManage?'':'')+'
'; }).join(''):'
No team tasks yet.
'; v.innerHTML=projectWorkspaceNavigation()+'
People & operations

Team Tasks

'+esc(workforceModuleDescription('tasks'))+'

'+formHtml+'

'+(canManage?'Assigned tasks':'My tasks')+'

'+rows+'
'; markPeriodList('workforce-tasks',v.querySelector('.wfList'),v.querySelectorAll('.wfList > .taskRow'),records,r=>r.data?.due_date||r.created_at); bindProjectWorkspaceNavigation(); if(window.customizeWorkforce)customizeWorkforce.onclick=openWorkforceCustomizer; if(window.taskAdd)taskAdd.onclick=async()=>{taskMsg.innerHTML='';try{await A('/workforce',{method:'POST',body:JSON.stringify({module:'tasks',data:{title:taskTitle.value,assignee_id:taskAssignee.value,due_date:taskDue.value,description:taskDescription.value,administrator_note:taskAdministratorNote.value,administrator_note_visibility:taskAdministratorNoteVisibility.value}})});await refreshData();workforceView()}catch(e){taskMsg.innerHTML='

'+esc(e.message)+'

'}}; document.querySelectorAll('.taskComplete').forEach(b=>b.onclick=()=>{ const panel=document.getElementById('taskPanel-'+b.dataset.id),task=records.find(x=>x.id===b.dataset.id),overdue=/^\d{4}-\d{2}-\d{2}$/.test(task?.data?.due_date||'')&&task.data.due_date':'')+'
'; panel.querySelector('.taskCancel').onclick=()=>panel.innerHTML=''; panel.querySelector('.taskSubmitDone').onclick=async()=>{const button=panel.querySelector('.taskSubmitDone'),msg=panel.querySelector('.taskDoneMsg'),lateExplanation=panel.querySelector('.taskLateExplanation')?.value.trim()||'';if(overdue&&!lateExplanation){msg.innerHTML='

Please explain why completion was not reported by the due date.

';return}button.disabled=true;try{const ids=[];for(const file of panel.querySelector('.taskDoneFiles').files){const result=await A('/documents',{method:'POST',body:JSON.stringify({name:file.name,data:await fileToBase64(file),category:'Task attachment',taskId:b.dataset.id})});ids.push(String(result.id))}await A('/workforce/'+encodeURIComponent(b.dataset.id),{method:'PATCH',body:JSON.stringify({data:{submission_notes:panel.querySelector('.taskDoneNotes').value,late_explanation:lateExplanation,attachment_ids:ids}})});await refreshData();workforceView()}catch(e){msg.innerHTML='

'+esc(e.message)+'

';button.disabled=false}}; }); async function reviewTask(id,status){const r=records.find(x=>x.id===id),notes=prompt(status==='Approved'?'Optional approval notes:':'Explain what changes are required:','');if(notes===null)return;await A('/workforce/'+encodeURIComponent(id),{method:'PATCH',body:JSON.stringify({data:{...r.data,status,review_notes:notes}})});await refreshData();workforceView()} document.querySelectorAll('.taskApprove').forEach(b=>b.onclick=()=>reviewTask(b.dataset.id,'Approved').catch(e=>alert(e.message))); document.querySelectorAll('.taskReject').forEach(b=>b.onclick=()=>reviewTask(b.dataset.id,'Changes requested').catch(e=>alert(e.message))); document.querySelectorAll('.taskAdminNote').forEach(b=>b.onclick=()=>{const r=records.find(x=>x.id===b.dataset.id),d=r?.data||{},panel=document.getElementById('taskPanel-'+b.dataset.id);panel.innerHTML='
';panel.querySelector('.taskCancel').onclick=()=>panel.innerHTML='';panel.querySelector('.taskSaveAdministratorNote').onclick=async()=>{const msg=panel.querySelector('.taskDoneMsg');try{await A('/workforce/'+encodeURIComponent(r.id),{method:'PATCH',body:JSON.stringify({data:{...d,administrator_note:panel.querySelector('.taskAdministratorNoteEditor').value,administrator_note_visibility:panel.querySelector('.taskAdministratorNoteVisibility').value}})});await refreshData();workforceView()}catch(e){msg.innerHTML='

'+esc(e.message)+'

'}}}); document.querySelectorAll('.taskEdit').forEach(b=>b.onclick=()=>{const r=records.find(x=>x.id===b.dataset.id),d=r.data||{},panel=document.getElementById('taskPanel-'+b.dataset.id);panel.innerHTML='
';panel.querySelector('.taskCancel').onclick=()=>panel.innerHTML='';panel.querySelector('.taskSaveEdit').onclick=async()=>{try{await A('/workforce/'+encodeURIComponent(r.id),{method:'PATCH',body:JSON.stringify({data:{...d,title:panel.querySelector('.taskEditTitle').value,assignee_id:panel.querySelector('.taskEditAssignee').value,due_date:panel.querySelector('.taskEditDue').value,description:panel.querySelector('.taskEditDescription').value}})});await refreshData();workforceView()}catch(e){alert(e.message)}}}); document.querySelectorAll('.taskDelete').forEach(b=>b.onclick=async()=>{if(!confirm('Delete this task and its attachments?'))return;try{await A('/workforce/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});await refreshData();workforceView()}catch(e){alert(e.message)}}); document.querySelectorAll('.taskFile').forEach(b=>b.onclick=async()=>{try{const r=await fetch(apiUrl('/documents/file?id='+encodeURIComponent(b.dataset.id)+'&download=1'),{credentials:'same-origin'});if(!r.ok)throw Error((await r.json().catch(()=>({}))).error||r.status);const blob=await r.blob(),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=b.dataset.name||'task-document';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000)}catch(e){alert('Unable to download document: '+e.message)}}); } function workforceView(){ const wfOrder=currentWorkforceOrder(); if(!wfOrder.includes(workforceModule)&&!['tasks','kpis'].includes(workforceModule)){ workforceModule=wfOrder[0]||'leave'; } if(workforceModule==='holidays')markAlertsRead('holidays'); const cfg=workforceConfig[workforceModule], records=(D.workforce||[]).filter(x=>x.module===workforceModule), canManage=U.role==='admin'||U.portalRole==='workspace_admin'||U.permissions?.full_workspace_admin, canManageTargets=workforceModule==='kpis'&&(canManage||U.permissions?.manage_kpi_targets), memberCanSubmit=['leave','incidents','referrals','kpis','contacts'].includes(workforceModule), canCreate=canManage||memberCanSubmit, canCustomize=wfOrder.length>1; const formFields=workforceModule==='contacts'&&!canManage ?cfg.fields.map(field=>field[0]==='scope'?['scope','Contact type','select:confidential::Confidential contact']:field) :workforceModule==='leave'&&!canManage ?cfg.fields.filter(field=>!['status','administrator_note'].includes(field[0])) :cfg.fields; const createFields=workforceModule==='leave' ?formFields.filter(field=>!['status','administrator_note','administrator_note_visibility'].includes(field[0])) :['incidents','kpis'].includes(workforceModule) ?formFields.filter(field=>!['administrator_note','administrator_note_visibility'].includes(field[0])) :formFields; if(workforceModule==='projects'){ projectManagementView(); return; } if(workforceModule==='tasks'){ teamTasksView(wfOrder); return; } const mine=(D.timeClockEntries||[]) .filter(e=>e.user_id===U.id) .slice(0,5); const myClock= '
'+ '
My time clock
'+ '

Recent attendance

'+ '
'+ (mine.length ?mine.map(e=> ''+ esc(displayDate(e.work_date))+' · '+ esc(tcFmtTime(e.time_in,firmTimeZone()))+'–'+ esc(tcFmtTime(e.time_out,firmTimeZone()))+' · '+ tcHoursClient(e)+' h'+ '' ).join('') :'No time clock entries yet.')+ '
'+ '
'; const formHtml=canCreate ?'
'+ '

'+(workforceModule==='kpis'?(canManageTargets?'Set a KPI target':'Submit a KPI report'):workforceModule==='incidents'&&!canManage?'Report incident':'Add '+cfg.label.replace(/s$/,''))+'

'+ '

'+ (canManageTargets ?'Create KPI targets for team members or departments. Authorized KPI managers can revise these targets.' :canManage ?'Workspace administrators can manage all records.' :workforceModule==='kpis' ?'Report your KPI results to your administrator and authorized KPI reviewers.' :workforceModule==='incidents' ?'Submit an incident report securely to your workspace administrator.' :workforceModule==='contacts' ?'Firm contacts are shared in the workspace. Confidential contacts are visible only to their creator.' :'Submit records available to your account.')+ '

'+ '
'+ createFields.map(f=>wfFieldHtml( f, f[0]==='full_name'&&workforceModule==='leave' ?(U.name||'') :f[0]==='department'&&workforceModule==='kpis' ?(U.department||'') :f[0]==='owner'&&workforceModule==='kpis' ?(U.name||'') :f[0]==='status' ?(workforceModule==='leave' ?'Pending' :workforceModule==='incidents' ?'Reported' :workforceModule==='kpis' ?'On Track' :'') :'' )).join('')+ (workforceModule==='policies'?'
Upload a PDF or editable document to display with this policy.
':'')+ '
'+ '
'+ '
' :''; const listHtml= '
'+ '

'+cfg.label+'

'+ (records.length ?records.map(r=> '
'+ '
'+ ''+esc(wfTitle(r))+''+ '
'+(workforceModule==='kpis'?''+esc(r.data?.record_type==='report'?'Team-member report':'KPI target')+'':'')+(workforceModule==='contacts'?''+esc(r.data?.scope==='firm'?'Firm contact':'Confidential contact')+'':'')+''+esc(wfSummary(r))+'
'+ (r.data?.notes?'
'+esc(r.data.notes)+'
':'')+ (['leave','incidents','kpis'].includes(workforceModule)&&r.data?.administrator_note?'
Administrator note '+esc(r.data.administrator_note_visibility==='confidential'?'Confidential':'Shared')+'
'+esc(r.data.administrator_note)+'
':'')+ (workforceModule==='policies'&&r.data?.document_id?'
'+policyDocumentHtml(r.data)+'
':'')+ '
'+ '
'+ (workforceModule==='leave'&&canManage&&r.data?.status!=='Approved'?'':'')+ (workforceModule==='leave'&&canManage&&r.data?.status!=='Denied'?'':'')+ ((canManage||(canManageTargets&&r.data?.record_type==='target')||(workforceModule==='contacts'&&r.data?.scope!=='firm'&&String(r.created_by)===String(U.id)))?'':'')+ ((canManage||(canManageTargets&&r.data?.record_type==='target')||(workforceModule==='contacts'&&r.data?.scope!=='firm'&&String(r.created_by)===String(U.id)))?'':'')+ '
'+ '
'+ '
' ).join('') :'
No '+cfg.label.toLowerCase()+' yet.
')+ '
'; v.innerHTML= (workforceModule==='kpis'?projectWorkspaceNavigation():'')+ '
'+ '
People & operations
'+ '

'+esc(cfg.label)+'

'+ '

'+esc(workforceModuleDescription(workforceModule))+'

'+ '
'+ '
'+ formHtml+ listHtml+ '
'; const workforceDate=r=>r.data?.start_date||r.data?.incident_date||r.data?.date||r.created_at; markPeriodList('workforce-'+workforceModule,v.querySelector('.wfList'),v.querySelectorAll('.wfList > .row'),records,workforceDate); document.querySelectorAll('[data-wfm]').forEach(b=>b.onclick=()=>{ workforceModule=b.dataset.wfm; tab='workforce';render(); }); if(window.customizeWorkforce){ customizeWorkforce.onclick=openWorkforceCustomizer; } if(window.wfAdd){ wfAdd.onclick=async()=>{ try{ const data=wfCollect(wfForm),policyFile=workforceModule==='policies'?document.getElementById('wfPolicyUpload')?.files?.[0]:null; if(policyFile){ const uploaded=await A('/documents',{method:'POST',body:JSON.stringify({name:policyFile.name,data:await fileToBase64(policyFile),category:'Policy document'})}); data.document_id=String(uploaded.id);data.document_name=policyFile.name;data.document_status=uploaded.status||'Processing'; } await A('/workforce',{ method:'POST', body:JSON.stringify({ module:workforceModule, data }) }); await refreshData(); workforceView(); }catch(x){ wfMsg.innerHTML='

'+esc(x.message)+'

'; } }; } if(canManage||canManageTargets||workforceModule==='contacts'){ document.querySelectorAll('.wfLeaveDecision').forEach(b=>b.onclick=async()=>{ const r=records.find(x=>x.id===b.dataset.id),status=b.dataset.status; const note=prompt(status==='Approved'?'Optional administrator note for this approval:':'Optional administrator note for this decline:',r?.data?.administrator_note||''); if(note===null)return; try{await A('/workforce/'+encodeURIComponent(r.id),{method:'PATCH',body:JSON.stringify({data:{...r.data,status,administrator_note:note,administrator_note_visibility:r.data?.administrator_note_visibility||'shared'}})});await refreshData();workforceView()}catch(x){alert(x.message)} }); document.querySelectorAll('.wfDelete').forEach(b=>b.onclick=async()=>{ if(!confirm('Delete this workforce record?'))return; try{ await A('/workforce/'+encodeURIComponent(b.dataset.id),{method:'DELETE'}); await refreshData(); workforceView(); }catch(x){ alert(x.message); } }); document.querySelectorAll('.wfEdit').forEach(b=>b.onclick=()=>{ let r=records.find(x=>x.id===b.dataset.id), box=document.getElementById('wfe-'+b.dataset.id); box.innerHTML= '
'+ formFields.map(f=>wfFieldHtml(f,r.data?.[f[0]])).join('')+ (workforceModule==='policies'?'
'+policyDocumentHtml(r.data,true)+'
Choose a new file to replace the current document.
':'')+ '
'+ '
'; box.querySelectorAll('.wfPolicyFile').forEach(button=>button.onclick=()=>openPolicyDocument(button)); box.querySelectorAll('.wfPolicyDownload').forEach(button=>button.onclick=()=>openPolicyDocument(button,true)); box.querySelector('.wfCancel').onclick=()=>box.innerHTML=''; box.querySelector('.wfSave').onclick=async()=>{ try{ const data=wfCollect(box),oldDocumentId=r.data?.document_id||'',replacement=box.querySelector('.wfPolicyReplacement')?.files?.[0],remove=box.querySelector('.wfPolicyRemove')?.checked===true; if(workforceModule==='policies'){ data.document_id=r.data?.document_id||'';data.document_name=r.data?.document_name||'';data.document_status=r.data?.document_status||''; if(replacement){const uploaded=await A('/documents',{method:'POST',body:JSON.stringify({name:replacement.name,data:await fileToBase64(replacement),category:'Policy document'})});data.document_id=String(uploaded.id);data.document_name=replacement.name;data.document_status=uploaded.status||'Processing'} else if(remove){data.document_id='';data.document_name='';data.document_status=''} } await A('/workforce/'+encodeURIComponent(r.id),{ method:'PATCH', body:JSON.stringify({data}) }); if(workforceModule==='policies'&&oldDocumentId&&(remove||replacement)&&oldDocumentId!==data.document_id)await A('/documents/'+encodeURIComponent(oldDocumentId),{method:'DELETE'}).catch(()=>{}); await refreshData(); workforceView(); }catch(x){ alert(x.message); } }; }); } document.querySelectorAll('.wfPolicyFile').forEach(b=>b.onclick=()=>openPolicyDocument(b)); document.querySelectorAll('.wfPolicyDownload').forEach(b=>b.onclick=()=>openPolicyDocument(b,true)); } function technicalRepliesHtml(issue){ const replies=Array.isArray(issue.replies)?issue.replies:[]; return '
'+ (replies.length ?replies.map(reply=>'
'+esc(reply.author_name||'Portal user')+' · '+esc(fmt(reply.created_at))+'
'+esc(reply.body)+'
').join('') :'

No replies yet.

')+ '
'+ '
'; } function bindTechnicalReplies(){ document.querySelectorAll('.techReplySend').forEach(button=>button.onclick=async()=>{ const box=document.getElementById('techReply-'+button.dataset.id); const body=box?.value.trim(); if(!body){if(box)box.focus();return} button.disabled=true; const old=button.textContent; button.textContent='Sending…'; try{ await A('/technical-issues?action=reply',{method:'POST',body:JSON.stringify({id:button.dataset.id,body})}); await technicalSupportView(); }catch(e){alert(e.message);button.disabled=false;button.textContent=old} }); } async function technicalSupportView(){ markAlertsRead('support'); v.innerHTML='

Technical Support & Suggestions

Loading portal submissions…

'; try{ const r=await A('/technical-issues'); const issues=r.issues||[]; if(U.role==='admin'){ const rows=issues.length ?issues.map(x=> '
'+ '
'+ ''+esc(x.subject)+''+ '
'+esc(x.submission_type==='suggestion'?'Portal suggestion':'Technical issue')+''+esc(x.status)+''+ ''+esc(x.org_name||'')+''+ ''+esc(x.reporter_name||'')+' · '+esc(x.reporter_email||'')+''+ 'Reported '+esc(new Date(x.client_timestamp).toLocaleString())+ (x.client_timezone?' · '+esc(x.client_timezone):'')+ ''+ '
'+ '

'+esc(x.description)+'

'+technicalRepliesHtml(x)+ '
'+ '
'+ (x.has_screenshot?'':'')+ ''+ ''+ '
'+ '
' ).join('') :'
No technical issues or suggestions have been submitted.
'; v.innerHTML= '
'+ '
Owner support desk
'+ '

Technical Support & Suggestions

'+ '

Review technical issues and portal suggestions submitted by clients and their team members.

'+ '
'+ '

Portal submissions

'+rows+'
'; markPeriodList('reports',v.querySelector('.card[style*="margin-top:20px"]'),v.querySelectorAll('.card[style*="margin-top:20px"] > .row'),issues,x=>x.client_timestamp||x.created_at); document.querySelectorAll('.techShot').forEach(b=>b.onclick=()=>{ window.open(apiUrl('/technical-issues?action=file&id='+encodeURIComponent(b.dataset.id)),'_blank','noopener'); }); document.querySelectorAll('.techResolve').forEach(b=>b.onclick=async()=>{ try{ await A('/technical-issues?action=resolve',{ method:'POST', body:JSON.stringify({ id:b.dataset.id, resolved:b.dataset.resolved!=='1' }) }); technicalSupportView(); }catch(e){alert(e.message)} }); document.querySelectorAll('.techDelete').forEach(b=>b.onclick=async()=>{ if(!confirm('Delete this portal submission and any attached screenshot?'))return; try{ await A('/technical-issues?action=delete',{ method:'POST', body:JSON.stringify({id:b.dataset.id}) }); technicalSupportView(); }catch(e){alert(e.message)} }); bindTechnicalReplies(); return; } const history=issues.length ?issues.map(x=> '
'+ '
'+esc(x.subject)+''+ '
'+esc(x.submission_type==='suggestion'?'Portal suggestion':'Technical issue')+''+esc(x.status)+''+ ''+esc(new Date(x.client_timestamp).toLocaleString())+''+ '

'+esc(x.description)+'

'+technicalRepliesHtml(x)+ '
'+ (x.has_screenshot?'':'')+ '
' ).join('') :'
You have not submitted any technical issues or suggestions.
'; v.innerHTML= '
'+ '
'+ '

Technical support & portal suggestions

'+ '

Report a technical problem or suggest an improvement directly to WellSpoke HR.

'+ '
'+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ '
'+ ''+ '
'+ '
'+ '
'+ '
'+ '

Your submissions

'+history+ '
'+ '
'; const now=new Date(); techStamp.textContent= 'Timestamp: '+now.toLocaleString()+ ' · '+(Intl.DateTimeFormat().resolvedOptions().timeZone||'Local time'); techType.onchange=()=>{ const suggestion=techType.value==='suggestion'; techDescriptionLabel.textContent=suggestion?'Describe your suggestion':'What happened?'; techDescription.placeholder=suggestion?'Explain the improvement you would like to see and how it would help.':'Describe what you were doing, what you expected to happen, and what happened instead.'; techScreenshotLabel.textContent=suggestion?'Screenshot (optional)':'Screenshot (required for technical issues)'; submitTech.textContent=suggestion?'Submit portal suggestion':'Submit technical issue'; }; markPeriodList('reports',v.querySelector('.two .card:last-child'),v.querySelectorAll('.two .card:last-child > .row'),issues,x=>x.client_timestamp||x.created_at); document.querySelectorAll('.techShot').forEach(b=>b.onclick=()=>{ window.open(apiUrl('/technical-issues?action=file&id='+encodeURIComponent(b.dataset.id)),'_blank','noopener'); }); bindTechnicalReplies(); submitTech.onclick=async()=>{ techMsg.innerHTML=''; const file=techScreenshot.files?.[0]; if(techType.value==='issue'&&!file){ techMsg.innerHTML='

Please attach a screenshot.

'; return; } if(file&&file.size>5*1024*1024){ techMsg.innerHTML='

Screenshot must be 5 MB or smaller.

'; return; } submitTech.disabled=true; const old=submitTech.textContent; submitTech.textContent='Submitting…'; try{ const screenshot=file?await new Promise((resolve,reject)=>{ const reader=new FileReader(); reader.onload=()=>resolve(reader.result); reader.onerror=()=>reject(Error('Screenshot could not be read')); reader.readAsDataURL(file); }):''; const stamp=new Date(); await A('/technical-issues',{ method:'POST', body:JSON.stringify({ subject:techSubject.value, description:techDescription.value, submissionType:techType.value, timestamp:stamp.toISOString(), timezone:Intl.DateTimeFormat().resolvedOptions().timeZone||'', screenshot, screenshotName:file?.name||'' }) }); techMsg.innerHTML='

'+(techType.value==='suggestion'?'Portal suggestion':'Technical issue')+' submitted to WellSpoke HR.

'; setTimeout(()=>technicalSupportView(),700); }catch(e){ techMsg.innerHTML='

'+esc(e.message)+'

'; }finally{ submitTech.disabled=false; submitTech.textContent=old; } }; }catch(e){ v.innerHTML='

Technical Support & Suggestions

'+esc(e.message)+'

'; } } async function securityView(target=v){ const org=W,userId=U.id; target.innerHTML='

Branding & security

Loading workspace settings…

'; let S=null; try{S=await A('/zoom')}catch(e){console.warn('Workspace brand settings unavailable',e)} if(!['connections','security'].includes(tab)||W!==org||U.id!==userId||!target.isConnected)return; const brandLogo=S?.brand?.hasLogo?''+esc(S.brand.name||'Workspace')+' brand':''; const brandSettings=S?.canManageBrand?'
Workspace settings

Workspace brand

This brand appears in the meeting workspace and meeting communications.

'+brandLogo+'
Drop a PNG or JPEG hereor click to choose · maximum 1 MB
':''; target.innerHTML=brandSettings+'

Authenticator MFA

'+(U.mfa?'Multi-factor authentication is enabled.':'Add an authenticator app for stronger account protection.')+'

Change password

Use at least 8 characters. Changing your password signs out active sessions and sends a security alert.

'; setup.onclick=async()=>{let r=await A('/mfa/setup',{method:'POST'});mfaBox.innerHTML=''+esc(r.secret)+'';me.onclick=async()=>{await A('/mfa/enable',{method:'POST',body:JSON.stringify({code:mc.value})});efficientRefresh()}}; cp.onclick=async()=>{try{await A('/password',{method:'POST',body:JSON.stringify({password:np.value})});alert('Password changed. Please sign in again.');login()}catch(x){alert(x.message)}}; if(S?.canManageBrand){ const brandDrop=target.querySelector('#brandDrop'),brandFile=target.querySelector('#brandFile'),brandName=target.querySelector('#brandName'),brandMsg=target.querySelector('#brandMsg'),saveBrandName=target.querySelector('#saveBrandName'); let pendingLogo=null,busy=false; const message=(text,error=false)=>{brandMsg.className=error?'err':'ok';brandMsg.textContent=text}; const current=()=>target.isConnected&&W===org&&U.id===userId; const selectFile=file=>{ if(!file||busy)return; pendingLogo=null; if(!['image/png','image/jpeg'].includes(file.type)){message('This file type is not supported. Export your logo as PNG or JPEG and choose it again.',true);return} if(!file.size||file.size>1024*1024){message('The image must be between 1 byte and 1 MB. Resize or compress it and choose it again.',true);return} busy=true;saveBrandName.disabled=true;message('Reading '+file.name+'…'); const reader=new FileReader(); const failed=()=>{if(!current())return;busy=false;saveBrandName.disabled=false;message('The image could not be read. Save a fresh PNG or JPEG copy on your device and choose it again.',true)}; reader.onerror=failed;reader.onabort=failed; reader.onload=()=>{ if(!current())return; const preview=new Image(); preview.onerror=failed; preview.onload=()=>{ if(!current())return; pendingLogo=reader.result;busy=false;saveBrandName.disabled=false; let image=target.querySelector('[data-brand-preview]'); if(!image){image=document.createElement('img');image.className='workspaceBrand';image.dataset.brandPreview='';brandDrop.before(image)} image.alt='Selected workspace logo preview';image.src=pendingLogo; message(file.name+' is ready. Select Save workspace brand to upload it.'); }; preview.src=reader.result; }; try{reader.readAsDataURL(file)}catch(e){failed()} }; saveBrandName.onclick=async()=>{ if(busy)return; if(!brandName.value.trim()){message('Enter a workspace brand name before saving.',true);return} busy=true;saveBrandName.disabled=true;saveBrandName.textContent='Saving…';message(pendingLogo?'Uploading workspace logo…':'Saving workspace brand…'); try{ await A('/zoom/brand',{method:'PUT',body:JSON.stringify({name:brandName.value.trim(),logo:pendingLogo})}); if(!current())return; pendingLogo=null;message('Workspace brand saved successfully.'); document.querySelectorAll('.portalWorkspaceBrand').forEach(image=>{image.style.visibility='';image.src=apiUrl('/zoom/brand')+'&v='+Date.now()}); }catch(e){if(current())message('Workspace brand could not be saved. '+e.message,true)} finally{if(current()){busy=false;saveBrandName.disabled=false;saveBrandName.textContent='Save workspace brand'}} }; const choose=()=>{if(!busy)brandFile.click()}; brandDrop.onclick=e=>{if(e.target!==brandFile)choose()};brandDrop.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();choose()}}; brandFile.onclick=e=>e.stopPropagation();brandFile.onchange=()=>{selectFile(brandFile.files[0]);brandFile.value=''}; brandDrop.ondragover=e=>{e.preventDefault();brandDrop.classList.add('drag')};brandDrop.ondragleave=()=>brandDrop.classList.remove('drag');brandDrop.ondrop=e=>{e.preventDefault();brandDrop.classList.remove('drag');if(e.dataTransfer.files.length!==1){message('Choose one PNG or JPEG logo at a time.',true);return}selectFile(e.dataTransfer.files[0])}; } } boot();