`; win.document.write(html); win.document.close(); } // ---- shared PDF shell ---- const _pdfLogo=(s=54)=>``; const _pdfBrandCss=` .brand{background:#0C1926;color:#fff;padding:20px 40px;display:flex;align-items:center;gap:16px;} .brand .nm{font-size:21px;font-weight:800;letter-spacing:.4px;} .brand .nm b{color:#E0B65A;font-weight:800;} .brand .sub{font-size:9px;color:#8DA1B0;letter-spacing:2.5px;text-transform:uppercase;margin-top:3px;} .brand .reg{margin-left:auto;text-align:right;font-size:10px;color:#8DA1B0;line-height:1.6;} .gbar{height:4px;background:linear-gradient(90deg,#C6912E,#F0D98A,#C6912E);} .wrap{padding:26px 40px;} .docrow{display:flex;justify-content:space-between;align-items:flex-end;border-bottom:2px solid #122334;padding-bottom:12px;margin-bottom:18px;} .docrow h1{font-size:22px;color:#C6912E;letter-spacing:1.5px;}`; function _pdfBrandHead(b,title,dateStr){ return `
${_pdfLogo(54)}
${(b.trading_name||'Heavenly Guards').replace(/ Security$/i,'')} Security
Operations · Vehicle Tracing & Recovery
${b.psira_number?'PSIRA '+b.psira_number+'
':''}${b.contact_email||''}${b.contact_phone?'
'+b.contact_phone:''}

${title}

${dateStr}
`; } function _pdfShell(title, bodyHtml){ const b=state.business||{}; const win=window.open('','_blank'); if(!win){toast('Allow pop-ups to download the PDF');return;} const today=new Date().toLocaleDateString('en-ZA',{day:'numeric',month:'long',year:'numeric'}); const css=`*{margin:0;padding:0;box-sizing:border-box;font-family:-apple-system,Segoe UI,Roboto,sans-serif;} body{color:#1a2b3c;font-size:13px;line-height:1.5;} ${_pdfBrandCss} .muted{color:#667;font-size:11px;} h2{font-size:12px;letter-spacing:1px;color:#C6912E;text-transform:uppercase;margin:22px 0 10px;} table{width:100%;border-collapse:collapse;margin:8px 0;} th{background:#122334;color:#fff;text-align:left;padding:9px 11px;font-size:10.5px;letter-spacing:.4px;} td{padding:9px 11px;border-bottom:1px solid #e5e8ec;font-size:12px;} .amt{text-align:right;font-variant-numeric:tabular-nums;} .cards{display:flex;gap:12px;margin:12px 0;} .stat{flex:1;background:#f6f4ef;border-radius:10px;padding:13px;} .stat .n{font-size:19px;font-weight:800;color:#122334;} .stat .l{font-size:10px;color:#667;} .foot{margin-top:30px;padding-top:14px;border-top:1px solid #e5e8ec;font-size:10px;color:#889;text-align:center;} @media print{.wrap{padding:20px 24px;}}`; const auto=String.fromCharCode(60)+'scr'+'ipt'+String.fromCharCode(62)+'window.onload=function(){setTimeout(function(){window.print();},350);}'+String.fromCharCode(60)+'/scr'+'ipt'+String.fromCharCode(62); win.document.write(`${title}${_pdfBrandHead(b,title,today)}${bodyHtml}
${b.trading_name||'Heavenly Guards Security'} · operating under Umvelo Risk Management · Generated ${today}
${auto} `); win.document.close(); } const _R=x=>'R'+((x||0)/100).toLocaleString('en-ZA',{minimumFractionDigits:2}); function generateTracerReportPDF(d){ if(!d){toast('Report still loading');return;} const t=d.tracer; const body=`
${d.caseCount}
Total cases
${d.earnings.invoices}
Invoiced
${_R(d.earnings.totalNet)}
Net earned
${_R(d.earnings.paidNet)}
Paid out

Tracer

${t.email?``:''} ${t.phone?``:''} ${t.id_number?``:''} ${t.psira_number?``:''} ${t.started_on?``:''}
Name${t.full_name}
Email${t.email}
Phone${t.phone}
ID number${t.id_number}
PSIRA${t.psira_number}
Started${t.started_on}

Cases (${d.cases.length})

${d.cases.map(c=>``).join('')}
DebtorAccountBankStageArrears
${c.debtor_name}${c.account_code||''}${c.bank||''}${c.stage}${c.arrears_cents?_R(c.arrears_cents):'—'}
`; _pdfShell('TRACER REPORT — '+t.full_name, body); } function generateBusinessReportPDF(p, tracers){ if(!p){toast('No figures yet');return;} const body=`
${_R(p.netProfit)}
Net profit (${p.margin}% margin)
${_R(p.billedToUmvelo)}
Billed to Umvelo
${_R(p.collected)}
Collected

Financial summary

Gross fees (ex-VAT)${_R(p.grossFees)}
VAT${_R(p.totalVat)}
Billed to Umvelo (incl. VAT)${_R(p.billedToUmvelo)}
Collected${_R(p.collected)}
Outstanding${_R(p.outstanding)}
Company share (30%)${_R(p.companyShare)}
Tracer payouts (70%)${_R(p.tracerPayouts)}
Business expenses${_R(p.expenses)}
Net profit${_R(p.netProfit)}
${tracers&&tracers.length?`

Per tracer

${tracers.map(t=>``).join('')}
TracerCasesRecoveredNet earned
${t.full_name}${t.case_count}${t.recovered}${_R(t.total_net)}
`:''}`; _pdfShell('BUSINESS REPORT', body); } function generateCaseReportPDF(c){ const iv=c._invoice; const body=`

Case summary

${c.vehicle&&c.vehicle.engine?``:''} ${c.vehicle&&c.vehicle.chassis?``:''} ${c.arrearsCents?``:''}
Debtor${c.debtor}
Reference${c.ref||c.id}
Account${c.type||''} · ${c.bank||''}
Vehicle${c.asset||''} ${c.reg?'· '+c.reg:''}
Engine${c.vehicle.engine}
Chassis / VIN${c.vehicle.chassis}
Stage${c.stage}
Tracer${c.tracer||'—'}
Arrears${_R(c.arrearsCents)}
${c.instruction?`

Instruction

Order${c.instruction.order||''}
Motivation${c.instruction.motivation||''}
`:''} ${c.verify&&c.verify.realAddr?`

Confirmed location

${c.verify.realGps?``:''}
Address${c.verify.realAddr}
GPS${c.verify.realGps}
`:''}

Activity

${(c.thread||[]).map(t=>``).join('')}
WhoUpdate
${t.w||''}${t.x||''}
`; _pdfShell('CASE REPORT — '+c.debtor, body); } // ---- generate completed & signed handover documents ---- function generateSignoffDocs(c, hw){ const b=state.business||{}; const today=new Date().toLocaleDateString('en-ZA',{day:'numeric',month:'long',year:'numeric'}); const now=new Date().toLocaleString('en-ZA'); const clientName=(c.estate&&c.estate.deceased)?(c.estate.attorney+' (estate representative)'):c.debtor; const gps=(c.handoverSummary&&c.handoverSummary.gps)||(c.verify&&c.verify.realGps)||'on file'; const sigLine=(label,name,signed)=>{ const isInk=typeof signed==='string'&&signed.indexOf('data:image')===0; return `
${ isInk?`` :signed?`${name}`:'' }
${label}${signed?' · signed at '+gps+' on '+now:' · not signed'}
`;}; const docs=[]; const inc=hw.include||{}; const refused=hw.refused; if(inc.surrender) docs.push({title:'VOLUNTARY SURRENDER',body:`

I, ${clientName}${c.idnum?' (ID '+c.idnum+')':''}, hereby voluntarily surrender the following vehicle to ${b.trading_name||'Heavenly Guards Security'}, acting on behalf of ${c.bank||'the finance provider'} via Umvelo Risk Management:

${c.vehicle&&c.vehicle.engine?``:''} ${c.vehicle&&c.vehicle.chassis?``:''}
Vehicle${c.asset||'—'}
Registration${c.reg||'—'}
Engine${c.vehicle.engine}
Chassis/VIN${c.vehicle.chassis}
Account ref${c.ref||c.id}

I confirm this surrender is voluntary and that the applicable fees and consequences have been explained to me.

${refused?'

CLIENT DECLINED TO SIGN. Document presented and explained; refusal recorded. '+(hw.refusedNote||'')+'

':''}
${sigLine('Client / '+clientName, clientName, !refused&&hw.signed.surrender_client)}${sigLine('Tracer / '+ROLES[state.role].who, ROLES[state.role].who, hw.signed.surrender_tracer)}
`}); // shared building blocks styled on the ABSA geolock specimens const hs=c.handoverSummary||{}; const tracerName=ROLES[state.role].who; const bestAddr=(c.intel&&c.intel.captured&&c.intel.captured.address)||(c.intel&&c.intel.addresses&&c.intel.addresses[0]&&(c.intel.addresses[0].address))||''; const rule=(lbl,val,w)=>`
${lbl}${val||''}
`; const boxes=(val,n)=>{const s=String(val==null?'':val).toUpperCase().slice(0,n);let o='
';for(let i=0;i${s[i]&&s[i]!==' '?s[i]:' '}`;}return o+'
';}; const condGrid=(()=>{const secs=hs.inspect?String(hs.inspect).split(', '):[]; const rows=INSPECT_SECTIONS.map(s=>{const line=secs.find(x=>x.indexOf(s.label+':')===0);const cond=line?line.split(': ')[1]:'—'; const col=cond==='Bad'?'#b23':cond==='Good'?'#1E8F63':'#334'; return `${s.label} ${cond==='Good'?'G':cond==='Fair'?'R':cond==='Bad'?'B':'—'} ${s.covers}`;}).join(''); return `${rows}
SECTIONC:COVERS
Abbreviations — C: Condition   G: Good   R: Reasonable   B: Bad. Photographs of every section are on the case record (${hs.photos||'—'} photos, GPS-tagged).
`;})(); if(inc.termination) docs.push({title:'NOTICE OF TERMINATION',body:`
Confidential

1  I, ${clientName}${c.idnum?' - '+c.idnum:''} ("the Consumer"), in terms of Section 127 of the National Credit Act, No 34 of 2005 ("the NCA") hereby give written notice to ${c.bank||'the Credit Provider'} ("the Credit Provider"), of the termination of the instalment/lease agreement ("the agreement") in respect of the following goods:

${rule('Full description of goods', c.asset, 170)}
${rule('Reg no', c.reg, 60)}
${rule('Chassis no', (c.vehicle&&c.vehicle.chassis)||'', 90)}

2  The aforesaid termination is subject to the following conditions:

2.1 The Consumer will, within 5 (five) business days from the date of this notice, return the goods to the Credit Provider's place of business during ordinary business hours or within such other period, time or place as the Credit Provider may agree. If the Credit Provider is already in possession of the goods, the Consumer hereby requires the Credit Provider to sell the goods.

2.2 The Credit Provider will, within 10 (ten) business days from the date of receiving the goods or the request to sell, give the Consumer written notice of the estimated value of the goods and the Consumer may, within 10 (ten) business days after receiving the notice of estimated value, unconditionally withdraw this notice and unless the Consumer is in default of the agreement, resume possession of the goods.

2.3 If the Consumer does not respond to the notice of estimated value, the Credit Provider will sell the goods as soon as practicable for the best price reasonably obtainable.

2.4 Upon the sale of the goods, the Credit Provider will credit/debit the Consumer's account with the proceeds of the sale, less any expenses reasonably incurred in respect of the sale of the goods and will inform the Consumer of the settlement value of the agreement immediately before the sale, the gross amount realised on the sale, the net proceeds of the sale after deducting permitted default charges, costs incurred in connection with the sale of the goods and the amount credited/debited to the Consumer's account.

3  The Consumer will be obliged to pay any shortfall between the amount credited to the Consumer's account and the settlement value immediately before the sale, within 10 (ten) business days after receiving demand notice, failing which the Credit Provider may commence legal proceedings in terms of the Magistrates' Courts Act.

4  Upon payment by the Consumer of the amount demanded, the agreement is terminated. Interest at the applicable rate is payable by the Consumer from the date of the demand notice as aforesaid until the date the outstanding amount is paid.

${rule('Signed at', gps, 60)}${rule('on', now, 24)}
${refused?'

CONSUMER DECLINED TO SIGN. Notice presented and explained; refusal recorded with GPS and photographs.

':''}
${sigLine('Consumer / '+clientName, clientName, !refused&&hw.signed.termination_client)}
`}); if(inc.inspection) docs.push({title:'INSPECTION REPORT (MOTOR VEHICLE)',body:`

I, the undersigned, ${tracerName}, do hereby declare that on ${today} I personally inspected at GPS ${gps} a certain vehicle of the following description:

${rule('CUSTOMER NAME', clientName)} ${rule('MODEL & MAKE', c.asset)} ${rule('REGISTRATION NUMBER', c.reg)} ${rule('KILOMETER READING', hs.km)} ${rule('ACCOUNT NUMBER', c.accNumber||c.ref)}
${rule('VIN NUMBER', (c.vehicle&&c.vehicle.chassis)||'')} ${rule('COLOUR', (c.vehicle&&c.vehicle.colour)||'')} ${rule('ENGINE NUMBER', (c.vehicle&&c.vehicle.engine)||'')} ${rule('FUEL LEVEL', hs.fuel)}

2. Condition recorded per section:

${condGrid}

Belongings: ${hw.belongings?'removed by client — confirmed':'none noted'}. Vehicle ${hs.tow?'not driveable — awaiting tow':'driven to trade centre'}.${hs.badSecs&&hs.badSecs.length?' Damage flagged: '+hs.badSecs.join(', ')+'.':''}

${refused?'

CLIENT DECLINED TO SIGN. Condition recorded with photographs and GPS.

':''}
${sigLine('Client / '+clientName, clientName, !refused&&hw.signed.inspection_client)}${sigLine('Inspector / '+tracerName, tracerName, hw.signed.inspection_tracer)}
`}); if(inc.vat264) docs.push({title:'VAT264 — VALUE-ADDED TAX DECLARATION',body:`

Declaration in respect of the supply of moveable second-hand goods or repossessed goods. To be used for registrable and non registrable goods. Information required in terms of section 20(8) of the Value-Added Tax Act, No. 89 of 1991.

Section 1 — Details of Owner
1.1 Name of owner selling the goods
${boxes(clientName,34)}
1.2 Identity number of owner
${boxes(c.idnum||'',13)}
1.4 Address of owner
${boxes(bestAddr,34)}
Section 2 — Description of Goods
2.1 Description / 2.3 Make and model
${boxes(c.asset,34)}
2.4 Registration number
${boxes(c.reg||'',12)}
2.6 Engine number
${boxes((c.vehicle&&c.vehicle.engine)||'',16)}
2.7 Odometer reading
${boxes(hs.km||'',8)}
2.8 VIN / Chassis number
${boxes((c.vehicle&&c.vehicle.chassis)||'',17)}
Declaration

I hereby declare that the goods were repossessed and the details furnished herein are true and correct.

${sigLine('Owner / '+clientName, clientName, !refused&&(hw.signed.vat264_client||hw.signed.surrender_client))}
Date (CCYYMMDD)
${boxes(new Date().toISOString().slice(0,10).replace(/-/g,''),8)}
`}); // always in the pack: trade-centre booking sheet + repossession location page docs.push({title:(b.trading_name||'HEAVENLY GUARDS')+' — TRADE CENTRE BOOKING-IN',body:` ${rule('CLIENT NAME', clientName)} ${rule('ACC NO', c.accNumber||c.ref)} ${rule('ADDRESS', bestAddr)} ${rule('VEHICLE', c.asset)} ${rule('REGISTRATION NO', c.reg)} ${rule('ENGINE NO', (c.vehicle&&c.vehicle.engine)||'')} ${rule('CHASSIS / VIN', (c.vehicle&&c.vehicle.chassis)||'')} ${rule('RMO / TRACER NAME', tracerName)}
${rule('DATE', today, 60)}(VEHICLE BOOKED IN)
${rule('ODOMETER AT BOOKING', hs.km)} ${rule('FUEL LEVEL', hs.fuel)}`}); docs.push({title:'REPOSSESSION LOCATION',body:`
GPS COORDINATES AT HANDOVER
${gps}
maps.google.com/?q=${encodeURIComponent(gps)}
${rule('Signed at (GPS)', gps, 90)}${rule('on', now, 24)}

Consumer: I agree with the condition of the Vehicle. I hereby confirm that I did remove all personal belongings out of the vehicle.

${refused?'

CONSUMER DECLINED TO SIGN — presented and recorded.

':''}
${sigLine('Consumer / '+clientName, clientName, !refused&&(hw.signed.surrender_client||hw.signed.inspection_client))}
`}); const win=window.open('','_blank'); if(!win){toast('Allow pop-ups to generate the documents');return;} const auto=String.fromCharCode(60)+'scr'+'ipt'+String.fromCharCode(62)+'window.onload=function(){setTimeout(function(){window.print();},400);}'+String.fromCharCode(60)+'/scr'+'ipt'+String.fromCharCode(62); const pages=docs.map(d=>`
${_pdfLogo(44)}
${(b.trading_name||'Heavenly Guards').replace(/ Security$/i,'')} Security
Operations · Vehicle Tracing & Recovery
${today}
${c.bank||''} ${c.type?'· '+c.type:''}

${d.title}

${d.body}
Stamped ${now} · GPS ${gps} · Case ${c.ref||c.id} · Generated by ${b.trading_name||'Heavenly Guards Security'} under Umvelo Risk Management
`).join(''); win.document.write(`Handover — ${c.debtor} ${pages}${auto} `); win.document.close(); } // ---- documents library screen ---- const TPL_CATS=[{k:'signoff',label:'Sign-off templates'},{k:'umvelo',label:'Umvelo documents'},{k:'legal',label:'Legal / NCA'},{k:'other',label:'Other'}]; const TPL_BANKS=['ABSA','WesBank','BMW','IMAS']; function documentsScreen(){ if(API.token && !state.templates && !state._tplLoading){ state._tplLoading=true; API.listTemplates().then(t=>{state.templates=t;state._tplLoading=false;render();}).catch(()=>{state._tplLoading=false;}); } const tpls=state.templates||[]; const byCat={}; tpls.forEach(t=>{(byCat[t.category]=byCat[t.category]||[]).push(t);}); return `
${aurora()}
Documents
Templates & shared documents
${icon('board',14,C.blue)}
Store templates and shared documents everyone can access. The signed handover documents are generated automatically at the sign-off stage of a case.
${state._tplLoading?`
Loading…
`:''} ${TPL_CATS.map(cat=>{const items=byCat[cat.k]||[];return `
${cat.label.toUpperCase()}
${items.length?items.map(t=>`
${icon('board',16,C.navy)}
${t.title}
${t.bank?t.bank+' · ':''}${t.filename||''}
Open ${state.role==='admin'?``:''}
`).join(''):`
None yet.
`} `;}).join('')} ${state.role==='admin'?``:''}
`; } function openTemplateUpload(){ const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Upload a template

Everyone can access it. Tag it so it's easy to find.

TITLE
CATEGORY
${TPL_CATS.map((c,i)=>``).join('')}
BANK (optional)
${TPL_BANKS.map(bk=>``).join('')}
`; app.appendChild(m); let cat='signoff',bank='',file=null; m.querySelectorAll('.tplCat').forEach(b=>b.onclick=()=>{cat=b.dataset.k;m.querySelectorAll('.tplCat').forEach(x=>{const on=x.dataset.k===cat;x.style.background=on?C.navy:'#fff';x.style.color=on?'#fff':C.steel;x.style.borderColor=on?C.navy:C.line;});}); m.querySelectorAll('.tplBank').forEach(b=>b.onclick=()=>{bank=b.dataset.b;m.querySelectorAll('.tplBank').forEach(x=>{const on=x.dataset.b===bank;x.style.background=on?C.navy:'#fff';x.style.color=on?'#fff':C.steel;x.style.borderColor=on?C.navy:C.line;});}); const fi=m.querySelector('#tplFile'); m.querySelector('.tplPick').onclick=()=>fi.click(); fi.onchange=()=>{file=fi.files[0];m.querySelector('#tplFileName').textContent=file?('✓ '+file.name):'';}; m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#tplCancel').onclick=()=>m.remove(); m.querySelector('#tplSave').onclick=async()=>{ const title=m.querySelector('#tplTitle').value.trim(); if(!title){toast('Add a title');return;} if(!file){toast('Choose a file');return;} try{ const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(rd.result.split(',')[1]);rd.onerror=rej;rd.readAsDataURL(file);}); await API.uploadTemplate({title,category:cat,bank:bank||undefined,filename:file.name,dataBase64:b64}); state.templates=null; toast('Template uploaded'); m.remove(); render(); }catch(e){ toast('Upload failed'); } }; } // ---- REAL camera capture: shows the device chooser (built-in camera, TimeStamp Camera, gallery) so GPS-stamping apps can be used; uploads to the case files ---- function snapPhoto(caseId,label,cb){ const inp=document.createElement('input'); inp.type='file'; inp.accept='image/*'; // no capture attr — lets the person pick TimeStamp Camera or gallery inp.style.display='none'; document.body.appendChild(inp); inp.onchange=async()=>{ const f=inp.files&&inp.files[0]; inp.remove(); if(!f){ if(cb)cb(false); return; } try{ const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(rd.result.split(',')[1]);rd.onerror=rej;rd.readAsDataURL(f);}); if(API.token&&caseId){ await API.post('/files/'+caseId+'/upload',{filename:(label||'photo')+'_'+Date.now()+'.jpg',kind:'photo',dataBase64:b64}); } toast('Photo captured'); if(cb)cb(true); }catch(e){ toast('Photo saved locally — upload failed'); if(cb)cb(true); } }; inp.click(); } /* ---- real finger-signature pad ---- */ function signaturePad(title,sub,existing,cb){ // cb(dataURL) | cb(null)=removed | dismissed=no call const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.7);z-index:84;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
${title}
${sub||'Sign with your finger in the box below.'}
Sign here
${existing?``:''}
`; app.appendChild(m); const cv=m.querySelector('#sigCv'); const hint=m.querySelector('#sigHint'); const dpr=Math.min(window.devicePixelRatio||1,2); const W=cv.clientWidth, H=170; cv.width=W*dpr; cv.height=H*dpr; const ctx=cv.getContext('2d'); ctx.scale(dpr,dpr); ctx.lineWidth=2.4; ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#122334'; let drawing=false, drew=false, last=null; const pos=e=>{const r=cv.getBoundingClientRect();const t=e.touches?e.touches[0]:e;return {x:t.clientX-r.left,y:t.clientY-r.top};}; const down=e=>{e.preventDefault();drawing=true;drew=true;hint.style.display='none';last=pos(e);}; const move=e=>{if(!drawing)return;e.preventDefault();const p=pos(e);ctx.beginPath();ctx.moveTo(last.x,last.y);ctx.lineTo(p.x,p.y);ctx.stroke();last=p;}; const up=()=>{drawing=false;}; cv.addEventListener('pointerdown',down); cv.addEventListener('pointermove',move); window.addEventListener('pointerup',up); const close=()=>{window.removeEventListener('pointerup',up);m.remove();}; m.querySelector('#sigCancel').onclick=close; m.querySelector('#sigClear').onclick=()=>{ctx.clearRect(0,0,W,H);drew=false;hint.style.display='grid';}; const rm=m.querySelector('#sigRemove'); if(rm) rm.onclick=()=>{close();cb(null);}; m.querySelector('#sigSave').onclick=()=>{ if(!drew){toast('Nothing signed yet');return;} // export on white for clean embedding in printed documents const out=document.createElement('canvas'); out.width=cv.width; out.height=cv.height; const octx=out.getContext('2d'); octx.fillStyle='#fff'; octx.fillRect(0,0,out.width,out.height); octx.drawImage(cv,0,0); close(); cb(out.toDataURL('image/png')); }; } /* ---- vehicle data checks (mirror of server lib/vehicleval.js) ---- */ function vehicleIssues(c){ const iss=[]; const sq=v=>(v||'').toString().trim().replace(/[\s-]/g,'').toUpperCase(); const reg=(c.reg||'').trim(), eng=(c.vehicle&&c.vehicle.engine||'').trim(), cha=(c.vehicle&&c.vehicle.chassis||'').trim(); const PLACE=/^(no\s*records?|none|n\/?a|unknown|tba|tbd|nil|not\s*supplied|no\s*reg(istration)?|pending|xxx+|-+|\?+)$/i; const VIN=/^[A-HJ-NPR-Z0-9]{17}$/i; if(reg){ if(PLACE.test(reg)) iss.push({l:'error',t:'Registration "'+reg+'" is placeholder text — the real plate is unknown. Clear it in Manage case.'}); else{ const r=sq(reg); if(VIN.test(r)) iss.push({l:'error',t:'Registration is 17 characters — that is a chassis/VIN, not a plate.'}); else if(r.length>=11) iss.push({l:'error',t:'Registration "'+reg+'" is too long for a SA plate — looks like an account/chassis fragment.'}); else if(r.length>=9) iss.push({l:'warn',t:'Registration "'+reg+'" is unusually long — verify against the vehicle.'}); if(cha&&r===sq(cha)) iss.push({l:'error',t:'Registration equals the chassis number — no plate was captured.'}); } } if(eng&&cha&&sq(eng)===sq(cha)) iss.push({l:'error',t:'Engine number EQUALS chassis number — physically impossible, one is wrong. Verify on the licence disc before relying on either.'}); if((eng||cha)&&!(c.vehicle&&(c.vehicle.make||c.vehicle.model))) iss.push({l:'warn',t:'Engine/chassis on file but no make or model — what car is the tracer looking for?'}); return iss; } function dataWarningBanner(c){ const iss=vehicleIssues(c); if(!iss.length) return ''; const hasErr=iss.some(i=>i.l==='error'); const col=hasErr?C.red:C.gold; return `
${icon('alert',19,col)}
Vehicle data ${hasErr?'is wrong':'needs a check'} — verify before field work
${iss.map(i=>`
${i.l==='error'?'✗':'⚠'}${i.t}
`).join('')}
${state.role==='admin'?`
Fix these in Manage case. A tracer identifies the physical car by these numbers.
`:''}
`; } /* ---- money helpers ---- */ function isoCycle(d){ d=d||new Date(); const t=new Date(Date.UTC(d.getFullYear(),d.getMonth(),d.getDate())); const dn=(t.getUTCDay()+6)%7; t.setUTCDate(t.getUTCDate()-dn+3); const f=new Date(Date.UTC(t.getUTCFullYear(),0,4)); const w=1+Math.round(((t-f)/86400000-3+((f.getUTCDay()+6)%7))/7); return 'Wk '+w; } const centsR=c=>'R'+(Number(c||0)/100).toLocaleString(undefined,{maximumFractionDigits:0}); function snapReceipt(caseId,cb){ // like snapPhoto but returns the uploaded file id const inp=document.createElement('input'); inp.type='file'; inp.accept='image/*'; inp.style.display='none'; document.body.appendChild(inp); inp.onchange=async()=>{ const f=inp.files&&inp.files[0]; inp.remove(); if(!f){cb(null);return;} try{ const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(rd.result.split(',')[1]);rd.onerror=rej;rd.readAsDataURL(f);}); const row=await API.post('/files/'+caseId+'/upload',{filename:'Receipt_'+Date.now()+'.jpg',kind:'receipt',dataBase64:b64}); toast('Receipt uploaded'); cb(row&&row.id||null); }catch(e){ toast('Receipt upload failed'); cb(null); } }; inp.click(); } function moneySheet(opts,cb){ // {title,sub,chips:[..],defKind,askAmount:true,askNote:true,confirm:'Save'} const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.6);z-index:82;display:flex;align-items:flex-end;justify-content:center;'; let kind=opts.defKind||((opts.chips||[])[0]||''); const chipRow=(opts.chips||[]).length?`
${opts.chips.map(k=>``).join('')}
`:''; m.innerHTML=`
${opts.title}
${opts.sub?`
${opts.sub}
`:'
'} ${chipRow} ${opts.askAmount!==false?`
R
`:''} ${opts.askNote?``:''}
`; app.appendChild(m); m.querySelectorAll('.msChip').forEach(b=>b.onclick=()=>{kind=b.dataset.k;m.querySelectorAll('.msChip').forEach(x=>{const on=x.dataset.k===kind;x.style.background=on?C.navy:'#fff';x.style.color=on?'#fff':C.steel;x.style.borderColor=on?C.navy:C.line;});}); m.querySelector('#msCancel').onclick=()=>m.remove(); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#msOk').onclick=async()=>{ const amtEl=m.querySelector('#msAmt'); const noteEl=m.querySelector('#msNote'); const rands=amtEl?parseFloat(amtEl.value):null; if(amtEl&&(!rands||rands<=0)){m.querySelector('#msErr').textContent='Enter a valid amount.';return;} const ok=m.querySelector('#msOk'); ok.textContent='Saving\u2026'; ok.disabled=true; try{ await cb({kind, rands, cents:rands?Math.round(rands*100):null, note:noteEl?noteEl.value.trim():''}); m.remove(); } catch(e){ m.querySelector('#msErr').textContent=e.message||'Failed.'; ok.textContent=opts.confirm||'Save'; ok.disabled=false; } }; } // ---- REAL GPS: grabs current coordinates ---- function grabGPS(cb){ if(!navigator.geolocation){ cb(null); return; } navigator.geolocation.getCurrentPosition( p=>cb(p.coords.latitude.toFixed(5)+', '+p.coords.longitude.toFixed(5)), ()=>cb(null), {enableHighAccuracy:true,timeout:9000,maximumAge:30000} ); } function openCaseManage(){ const c=CASES.find(x=>x.id===state.caseId); const stages=[['placed','Placed'],['tracing','In trace'],['blocked','Blocked'],['recovered','Recovered'],['delivered','Delivered'],['invoiced','Invoiced'],['closed','Closed'],['cancelled','Cancelled']]; const isAdmin=state.role==='admin'; const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Manage case

${c.debtor} · ${c.ref||c.id}

CHANGE STATUS
${stages.map(([k,l])=>``).join('')}
${isAdmin?`
EDIT DETAILS (admin)
${(()=>{const fl=(id,lab,val,ph)=>`
${lab}
`; const v=c.vehicle||{}; return `
${fl('ceName','Debtor name',c.debtor)}
${fl('ceAcc','Account number (ACC)',c.accNumber,'e.g. 99541313')}${fl('ceRef','Umvelo reference',c.ref)}
${fl('ceType','Account type',c.type,'ABQ / TB2')}${fl('ceBank','Bank',c.bank)}
${fl('ceArrears','Arrears (R)',c.arrearsCents?(c.arrearsCents/100).toFixed(2):'','0.00')}${fl('ceTask','Instruction',c.task)}
THE VEHICLE — verified at handover
${fl('ceYear','Year',v.year)}${fl('ceMake','Make',v.make)}${fl('ceColour','Colour',v.colour)}
${fl('ceModel','Model',v.model)}
${fl('ceReg','Registration',c.reg,'CAA123456')}
${fl('ceEngine','Engine number',v.engine)}
${fl('ceChassis','Chassis / VIN',v.chassis)}
Leave a field empty to clear a wrong value. Engine and chassis are what a tracer checks against the car — get these right.
`;})()}
UMVELO COLLECTOR & MANAGER (from the instruction footer)

The collector becomes this case's Umvelo agent — added as a new agent automatically if not on the system. The manager is the escalation contact.

DANGER ZONE
`:''}
`; app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#ceCancel').onclick=()=>m.remove(); m.querySelectorAll('.stageBtn').forEach(b=>b.onclick=async()=>{ const stage=b.dataset.k; if(stage===c.stage){toast('Already '+stage);return;} const prev=c.stage; c.stage=stage; m.remove(); toast('Status → '+stage); render(); if(API.token){ try{ await API.changeCaseStatus(c.id,stage); }catch(e){ c.stage=prev; toast('Failed: '+(e.message||'')); render(); } } }); if(isAdmin){ m.querySelector('#ceUmvSave').onclick=async()=>{ const cn=m.querySelector('#ceColName').value.trim(), cp=m.querySelector('#ceColPhone').value.trim(); const mn=m.querySelector('#ceMgrName').value.trim(), mp=m.querySelector('#ceMgrPhone').value.trim(); if(!cn){toast('Enter the collector name');return;} c.contacts=c.contacts||{}; c.contacts.collector=[cn,cp]; if(mn) c.contacts.manager=[mn,mp]; if(API.token){ try{ const r=await API.setUmveloPeople(c.id,{collectorName:cn,collectorPhone:cp,managerName:mn,managerPhone:mp}); c.agent=r.agentName||cn; toast(r.created?'Collector added as NEW Umvelo agent + linked':'Collector linked as case agent'); }catch(e){ toast('Save failed: '+(e.message||'')); } } else { c.agent=cn; toast('Collector & manager saved'); } m.remove(); render(); }; m.querySelector('#ceSave').onclick=async()=>{ const g=id=>{const el=m.querySelector('#'+id);return el?el.value.trim():undefined;}; const arrRands=g('ceArrears'); const payload={ debtor_name:g('ceName'), ref:g('ceRef'), acc_number:g('ceAcc'), account_code:g('ceType'), bank:g('ceBank'), task:g('ceTask'), arrears_cents: arrRands===''?'':(arrRands!=null?Math.round(parseFloat(arrRands.replace(/[^\d.]/g,''))*100)||0:undefined), v_year:g('ceYear'), v_make:g('ceMake'), v_model:g('ceModel'), v_colour:g('ceColour'), v_reg:g('ceReg'), v_engine:g('ceEngine'), v_chassis:g('ceChassis') }; // reflect locally straight away c.debtor=payload.debtor_name||c.debtor; c.ref=payload.ref; c.accNumber=payload.acc_number||null; c.type=payload.account_code||c.type; c.bank=payload.bank||c.bank; c.task=payload.task||c.task; c.reg=payload.v_reg||null; if(payload.arrears_cents!==undefined&&payload.arrears_cents!=='') c.arrearsCents=payload.arrears_cents; c.vehicle=Object.assign(c.vehicle||{},{year:payload.v_year,make:payload.v_make,model:payload.v_model,colour:payload.v_colour,engine:payload.v_engine,chassis:payload.v_chassis}); m.remove(); toast('Case updated'); render(); if(API.token){ try{ await API.editCase(c.id,payload); const full=await API.loadCase(c.id); const k=CASES.findIndex(x=>x.id===c.id); if(k>-1){CASES[k]=full;render();} }catch(e){ toast('Save failed — refresh to check'); } } }; m.querySelector('#ceDelete').onclick=async()=>{ if(!window.confirm('Delete '+c.debtor+'? This cannot be undone.')) return; const id=c.id; m.remove(); if(API.token){ try{ await API.deleteCase(id); CASES=CASES.filter(x=>x.id!==id); toast('Case deleted'); state.screen='board'; render(); }catch(e){ toast('Delete failed'); } } else { CASES=CASES.filter(x=>x.id!==id); state.screen='board'; render(); } }; } } function generateTracerStatementPDF(d){ if(!d){toast('Statement still loading');return;} const t=d.tracer; const R=x=>'R'+((x||0)/100).toLocaleString('en-ZA',{minimumFractionDigits:2}); const period=new Date().toLocaleDateString('en-ZA',{month:'long',year:'numeric'}); const invoicedCases=(d.cases||[]).filter(c=>c.stage==='invoiced'||c.stage==='recovered'||c.stage==='delivered'); const body=`
${R(d.earnings.totalNet)}
Total net earned
${R(d.earnings.paidNet)}
Paid to date
${R((d.earnings.totalNet||0)-(d.earnings.paidNet||0))}
Outstanding

Statement — ${period}

${t.id_number?``:''} ${t.email?``:''}
Tracer${t.full_name}
ID number${t.id_number}
Email${t.email}
Invoices raised${d.earnings.invoices}

Recovered / invoiced cases

${invoicedCases.length?invoicedCases.map(c=>``).join(''):''}
DebtorAccountStage
${c.debtor_name}${c.account_code||''}${c.stage}
No recovered cases in this period.

This statement reflects the tracer's net share (70% of confirmed fees, less any advances) per the agreed terms. Figures are subject to final reconciliation against Umvelo's confirmed statements.

`; _pdfShell('TRACER STATEMENT — '+t.full_name, body); } function moneyCard(c){ if(c.arrearsCents==null) return ''; const rands=(cents)=>'R'+(cents/100).toLocaleString('en-ZA',{minimumFractionDigits:2}); // how long owed let ageTxt=''; let ageMonths=null; if(c.arrearsSince){ const since=new Date(c.arrearsSince); const now=new Date('2026-07-07'); ageMonths=Math.max(0,Math.round((now-since)/(30.44*86400000))); ageTxt=ageMonths+' month'+(ageMonths===1?'':'s'); } const behind=c.instalmentsBehind; const sev = ageMonths==null?C.gold : ageMonths>=6?C.red : ageMonths>=3?C.gold : C.blue; const sevSoft = sev===C.red?C.redSoft : sev===C.gold?C.goldSoft : '#E8F0FA'; return `
${icon('wallet',18,'#fff')}AMOUNT OWED ${ageTxt?`⏱ ${ageTxt} overdue`:''}
${rands(c.arrearsCents)}
in arrears${c.arrearsSince?' since '+new Date(c.arrearsSince).toLocaleDateString('en-ZA',{month:'long',year:'numeric'}):''}
${behind!=null?`
${behind}
instalments behind
`:''} ${c.instalmentCents?`
${rands(c.instalmentCents)}
per instalment
`:''}
${(c.task||'').toLowerCase().indexOf('settle')>-1||(c.task||'').toLowerCase().indexOf('pay')>-1||(c.task||'').toLowerCase().indexOf('uplift')>-1?`
${icon('alert',13,sev)}
Instruction allows settle in full or uplift. If the debtor pays now, log the settlement below and capture proof.
`:''} ${(state.role==='tracer'||state.role==='admin')?``:''} ${c.settlement?`
Settlement ${c.settlement.status}: ${rands(c.settlement.amountCents)}${c.settlement.reference?' · ref '+c.settlement.reference:''}. ${c.settlement.status==='agreed'?'Awaiting proof of payment.':'Proof captured.'}
`:''} ${(c.invoiceRequest&&state.role==='admin'&&c.stage!=='invoiced'&&c.stage!=='closed')?`
${icon('wallet',16,C.green)}
UMVELO CONFIRMED THE FEE
${money0(c.invoiceRequest.feeCents)} excl VAT${c.invoiceRequest.ref?' · statement '+c.invoiceRequest.ref:''} — by ${c.invoiceRequest.by}
`:''} ${(state.role==='admin'&&!c.invoiceRequest&&(c.stage==='recovered'||c.stage==='delivered'))?``:''} ${(state.role==='umvelo'&&(c.stage==='recovered'||c.stage==='delivered')&&c.stage!=='invoiced')?``:''} ${c.stage==='invoiced'?`
Invoiced. This case has been billed — see the Billing dashboard for the split.
`:''}
`; } function vehicleCard(c){ const v=c.vehicle||{}; const row=(label,val,mono)=>`
${label} ${val||'—'}
`; return `
${icon('truck',19,C.dawn)}
THE VEHICLE
${v.year||''} ${v.make||''} ${v.model||c.asset}
${v.colour&&v.colour!=='—'?`${v.colour}`:''}
${row('REGISTRATION / PLATE',c.reg,true)} ${row('MAKE',v.make)} ${row('MODEL',v.model)} ${row('ENGINE NUMBER',v.engine,true)} ${row('CHASSIS / VIN',v.chassis,true)}
ACCOUNT${c.type} · ${c.bank}
${(v.engine&&v.engine.indexOf('verify')>-1)||(v.chassis&&v.chassis.indexOf('verify')>-1)?`
${icon('alert',13,C.gold)}
Engine/chassis to be verified against the licence disc & contract at handover.
`:''}
`; } function expensesCard(c){ if(!API.token) return ''; const key=c.id; state._exp=state._exp||{}; if(state._exp[key]===undefined && !state._expLoad){ state._expLoad=true; API.listCaseExpenses(key).then(r=>{state._exp[key]=r||[];state._expLoad=false;render();}).catch(()=>{state._exp[key]=[];state._expLoad=false;}); } const rows=state._exp[key]||[]; const stC={pending:C.gold,approved:C.blue,rejected:C.red,billed:C.purple,settled:C.green,absorbed:C.red}; const stL={pending:'PENDING',approved:'APPROVED',rejected:'REJECTED',billed:'BILLED',settled:'PAID BACK',absorbed:'ABSORBED'}; const open=rows.filter(e=>['pending','approved','billed'].includes(e.status)).reduce((s,e)=>s+Number(e.amount_cents),0); const isAdmin=state.role==='admin'; const line=e=>{const col=stC[e.status]||C.steel; return `
${icon('wallet',14,col)}
${e.kind} · ${centsR(e.amount_cents)}
${e.spent_on?String(e.spent_on).slice(0,10):''}${e.logged_by_name?' · '+e.logged_by_name:''}${e.note?' · '+e.note:''}
${stL[e.status]||e.status}
${!e.receipt_file_id&&['pending','approved'].includes(e.status)?``:''} ${e.receipt_file_id?`${icon('check',11,C.green)} Receipt on file`:''} ${isAdmin&&e.status==='pending'?` `:''} ${isAdmin&&e.status==='approved'?``:''} ${isAdmin&&e.status==='billed'?``:''}
`;}; return `
${icon('wallet',14,C.gold)}Recovery expenses ${open?`${centsR(open)} open`:''}
Fuel, tolls, jump-starts — anything paid to move this car. HGS pays, bills Umvelo, gets it back. Receipt required before approval.
${rows.length?`
${rows.map(line).join('')}
`:`
${state._expLoad?'Loading\u2026':'No expenses on this case.'}
`} ${(state.role==='tracer'||isAdmin)?``:''}
`; } function caseView(){ const c=CASES.find(x=>x.id===state.caseId); if(!c) return ''; const r=state.role; const blk=c.blocked?BLOCKS[c.blocked]:null; const contactRow=(lbl,pair,col,ico)=>{ if(!pair||!pair[0]) return ''; const tel=(pair[1]||'').replace(/\s/g,''); return `
${icon(ico,16,col)}
${lbl.toUpperCase()}
${pair[0]}
${tel?`${icon('phone',14,'#fff')} Call`:''}
`; }; const tags={place:['PLACED',C.blue],allocate:['ASSIGNED',C.gold],update:['FIELD UPDATE',C.green],block:['BLOCKED',C.red],resolve:['UNBLOCKED',C.green]}; return `
${aurora()}
${c.debtor}
${c.accNumber?`ACC ${c.accNumber}`:''} ${(()=>{const at=acctType(c.type);if(!at)return '';const p=PRIO[at.priority]||PRIO.standard;return ``;})()}
${c.asset} · ${c.reg} · ${c.type}${c.ref?' · '+c.ref:''}
${c.waitingOn&&c.waitingOn!=='none'?(()=>{const P={tracer:['Tracer',C.blue,48],umvelo:['Umvelo',C.gold,72],bank:['Bank',C.purple,336],debtor:['Debtor',C.steelLite,24]}[c.waitingOn]||['—',C.steel,72]; const h=c.waitingSince?Math.round((Date.now()-new Date(c.waitingSince).getTime())/3600000):0; const od=h>P[2]; return ``;})():''}
${vehicleCard(c)}
${moneyCard(c)}
${dataWarningBanner(c)} ${(()=>{const s=slaInfo(c);if(!s)return '';const bg=s.urgent?C.redSoft:s.days<=14?C.goldSoft:C.greenSoft;return `
${icon('clock',15,s.col)}
Mandate ${s.days<=0?'has expired':'expires in '+s.days+' days'} — ${c.expires}. ${s.urgent?'Recover or return to Umvelo before it lapses; an expired mandate may mean no fee.':'On track.'}
`;})()} ${blk?`
${icon('lock',21,C.red)}
Blocked — waiting on Umvelo
${blk.label}. Needed before the tracer can proceed.
${r==='umvelo'?`` :`
Waiting for Umvelo to issue this.
`}
`:''}
${(r==='tracer'||r==='admin')&&!blk?``:''}
${assignmentCard(c)} ${(r==='admin'||r==='umvelo')?``:''} ${nextActionCard(c)} ${instructionCard(c)} ${filesCard(c)} ${expensesCard(c)} ${estateCard(c)} ${impoundCard(c)} ${intelCard(c)} ${(c.contacts&&(c.contacts.collector||c.contacts.manager||c.contacts.saps||c.contacts.sheriff||c.contacts.tow||c.contacts.estate))?`
${icon('siren',14,C.gold)}Contacts for this case
${c.contacts.estate?contactRow('Estate attorney',c.contacts.estate,C.purple,'shield'):''} ${contactRow('Umvelo collector (agent)',c.contacts.collector,C.blue,'alert')} ${c.contacts.manager?contactRow('Umvelo manager — escalation',c.contacts.manager,C.navy,'users'):''} ${contactRow('Nearest SAPS',c.contacts.saps,C.red,'siren')} ${contactRow('Sheriff of the Court',c.contacts.sheriff,C.purple,'bank')} ${contactRow('Towing (Umvelo)',c.contacts.tow,C.gold,'truck')}
${icon('pin',12,C.steel)} SAPS & Sheriff matched to case location.
`:''}
${icon('chat',14,C.gold)}Case activity
${c.thread.map(m=>{const R=ROLES[m.r]||{color:C.steel};const tg=tags[m.k];return `
${icon('shield',15,'#fff')}
${m.w}${tg?`${tg[0]}`:''}${m.t}
${m.x}
`}).join('')}
`; } /* ---- field ---- */ function field(){ const c=CASES.find(x=>x.id===state.caseId); if(!c) return ''; // build "do this next" from live data if steps weren't provided let nx=(c.steps&&c.steps[0])||null; if(!nx){ const num=(c.intel&&c.intel.numbers&&c.intel.numbers[0])||null; const addr=(c.intel&&c.intel.addresses&&(c.intel.addresses.find(a=>c.verify&&c.verify.addr&&Object.values(c.verify.addr).includes('correct'))||c.intel.addresses[0]))||null; if(num) nx={k:'call',v:num.n||num,m:'Best number on file'}; else if(addr) nx={k:'drive',v:addr.a||addr,m:'Best address on file'}; else nx={k:'drive',v:'No contact details on file',m:'Check case intelligence'}; } // what does the instruction allow? const taskL=((c.task||'')+' '+(c.rule||'')).toLowerCase(); const allowSettle=/settle|collect debt|payment/.test(taskL); const allowUplift=/uplift|repossess|recover|surrender/.test(taskL) || !allowSettle; return `
${aurora()}
${c.debtor}
${c.asset} · ${c.reg}
${icon('target',19,C.gold)}
YOUR JOB
${c.task||'—'}
${c.rule?`
${icon('alert',18,C.red)}
${c.rule}
`:''}
${icon('zap',13,C.navy)}DO THIS NEXT
${nx.k==='call'?'CALL FIRST':'GO TO ADDRESS'}
${nx.v}
${nx.m}
${icon('shield',13,C.gold)}INSTRUCTION OUTCOMES
${allowUplift?``:''} ${allowSettle?``:''} ${nextActionCard(c)} ${c.sighting?`
${icon('pin',13,C.blue)}LAST SIGHTING
${icon('pin',20,'#fff')}
${c.sighting.where}
${c.sighting.coords} · ${c.sighting.dist}
`:''} ${tradeCentreCard(c)} ${feeCard(c)} ${intelCard(c)}

Moves the case to Office Review, then to the agent to authorise.

`; } function tradeCentreCard(c){ const tc=centreFor(c); const overridden=!!c.centreOverride; const early=tc.delivery.indexOf('15:30')>-1; return `
${icon('bank',13,C.gold)}DELIVER TO — ABSA TRADE CENTRE
${icon('bank',17,C.dawn)}
DELIVER TO · ABSA
${tc.name}
${overridden?`UMVELO SET`:''}
${icon('pin',15,C.steel)}
${tc.addr}
OPENING HOURS
${tc.hours}
VEHICLE DELIVERY
${tc.delivery}
${tc.note?`
${icon('alert',14,C.gold)}
${tc.note}
`:''} ${icon('nav',15,'#fff')} Navigate to trade centre
`; } function feeCard(c){ const rule = bankRule(codeFromRef(c.ref||c.id)); if(rule.model==='flat'){ const share = Math.round(rule.flat*0.7); return `
${icon('nav',13,C.gold)}YOUR EARNING — ${rule.bank.toUpperCase()}
${rule.bank} · flat fee per recovery
YOU EARN
R${share.toLocaleString()}
${icon('bank',14,C.blue)}
BMW pays a flat fee per recovery. Distance drive-in only if Umvelo approves it. Approved fuel/expenses add on top.
`; } const share = b => b.fee===0 ? 'No fee' : 'R'+Math.round(b.fee*0.7).toLocaleString(); const rows = DRIVE_IN_BANDS.map(b=>`
${b.label} ${share(b)}
`).join(''); return `
${icon('nav',13,C.gold)}YOUR DRIVE-IN EARNING — ABSA
Calculated automatically from your Start & End KM. This is your share.
${rows}
${icon('alert',14,C.gold)}
0–100 km must be driven in (not towed), or you pay towing. Adhoc R250 fee currently suspended.
`; } function assignmentCard(c){ const st = ASSIGN[c.assignStatus] || ASSIGN.accepted; const r = state.role; const canReassign = r==='admin' || r==='umvelo'; const initials = (c.tracer||'?').split(' ').map(w=>w[0]).join(''); // action button depends on status + role let action=''; if(c.assignStatus==='auto' && r==='admin') action=``; else if(c.assignStatus==='confirmed' && r==='tracer' && (!state.userName||c.tracer===state.userName)) action=``; else if(c.assignStatus==='auto' && r!=='admin') action=`
${icon('lock',12,C.steel)} Awaiting office to confirm the assignment.
`; else if(c.assignStatus==='confirmed' && r!=='tracer') action=`
${icon('lock',12,C.steel)} Confirmed — awaiting ${c.tracer} to accept.
`; const log = (c.assignLog||[]).map(l=>`
${icon('users',12,C.steel)}
${l.from}→${l.to} · by ${l.by}
${l.reason} · ${l.time}
`).join(''); return `
${icon('users',14,C.gold)}Assigned tracer
${st.label.toUpperCase()}
${initials}
${c.tracer||'Unassigned'}
${st.note} · via ${c.assignedBy||'—'}
${action} ${canReassign?``:''} ${log?`
REASSIGNMENT HISTORY
${log}
`:''}
`; } function nextActionCard(c){ const na = c.nextAction; if(!na) return ''; const d = dispo(na.dispo); const overdue = na.overdue; const col = overdue ? C.red : (d && d.good ? C.green : C.gold); const soft = overdue ? C.redSoft : (d && d.good ? C.greenSoft : C.goldSoft); return `
${icon('clock',13,col)}${overdue?'OVERDUE FOLLOW-UP':'NEXT FOLLOW-UP'}
${icon(d?d.icon:'clock',19,col)}
${na.label}
${na.when}${na.place?' · '+na.place:''}
${overdue?`DUE`:''}
${na.dispo==='ptp'||na.dispo==='pts'||na.dispo==='arrangement'?`
${icon('alert',13,col)}
Tracked commitment. If not honoured by the due time, it auto-flags as broken and the office is alerted.
`:''}
`; } function filesCard(c){ const files=c.files||[]; const rows=files.map((f,i)=>{const m=fileMeta(f.t); return `
${icon('board',18,m.color)}PDF
${f.n}
${m.label} · ${f.by} · ${f.d}
${f.signed?`SIGNED`:''} ${f.declined?`DECLINED`:''} ${f.summary?`SUMMARY`:''} ${icon('chev',16,C.steelLite)}
`;}).join(''); return `
${icon('lock',13,C.gold)}CASE FILES — ORIGINAL DOCUMENTS
${files.length}
The documents this case was built from. Kept permanently for verification.
${rows||`
No documents yet.
`}
`; } function estateCard(c){ const e=c.estate; if(!e||!e.deceased) return ''; const r=state.role; return `
${icon('shield',13,C.purple)}DECEASED ESTATE — HANDLE VIA ATTORNEY
${icon('shield',20,'#fff')}
Debtor is deceased
Recovery proceeds through the estate
${e.note}
ESTATE ATTORNEY / THIRD PARTY
${icon('users',18,'#fff')}
${e.attorney}
${e.firm}
${icon('phone',14,'#fff')} Call
${icon('alert',14,C.gold)}
Do not approach the family for a normal uplift. The third party or attorney signs the forms and hands over the vehicle. In the handover, the estate representative signs in place of the client.
${r==='umvelo'?`
${icon('check',13,C.green)} With you to manage the estate channel.
`:`
${icon('lock',13,C.steel)} Handed to Umvelo to manage via the estate.
`}
`; } function impoundCard(c){ const im=c.impound; if(!im) return ''; const p=POUNDS[im.pound]||POUNDS.ndabeni; const r=state.role; const docsChecked=Object.values(im.releaseDocs||{}).filter(Boolean).length; const docsTotal=p.releaseDocs.length; const ready = im.feePaid && docsChecked===docsTotal; // fee-record attachments const feeDocs=[]; if(im.noticeAttached) feeDocs.push('Impound notice'); if(im.feePaid) feeDocs.push('Proof of payment'); return `
${icon('lock',13,C.red)}VEHICLE IMPOUNDED — RELEASE REQUIRED
${icon('alert',20,'#fff')}
${p.name}
Notice ${im.notice} · ${im.offence}
RELEASE FEE
R${im.fee.toLocaleString()}
PAY AT
${p.payAt}
FEE RECORD
${im.feePaid?icon('check',11,'#fff'):''}
Proof of payment (ABSA)${im.feePaid?'ATTACHED':'PENDING'}
${im.status==='raised'?`
${icon('lock',17,C.gold)}
Handed back to Umvelo — awaiting ABSA payment
Umvelo is following up so ABSA pays the R${im.fee.toLocaleString()} release fee. The case resumes once payment is confirmed.
${r==='umvelo'?``:''}
`:im.status==='found'?`
${icon('bank',14,C.blue)}
ABSA pays the release fee. Attach the notice, then raise it to Umvelo — they follow up so ABSA pays.
${(r==='tracer'||r==='admin')?``:''} ${!im.noticeAttached?`

Attach the impound notice first.

`:''}`:''} ${im.feePaid?`
${icon('check',15,C.green)}ABSA paid the release fee — proof on file. Proceed with release.
`:''}
RELEASE DOCUMENT PACK ${docsChecked}/${docsTotal}
Required by ${p.authority} to release the vehicle.${im.feePaid?'':' Unlocks once the fee is paid.'}
${p.releaseDocs.map((d,i)=>{const on=im.releaseDocs&&im.releaseDocs[i]; return ``;}).join('')}
${icon(ready?'check':'lock',16,ready?C.green:C.steel)}
${ready?'Ready to collect from the pound — fee paid & pack complete.':'Complete the fee payment and document pack to release.'}
`; } function intelCard(c){ const q=c.intel; if(!q) return ''; c.verify=c.verify||{num:{},addr:{}}; const sc=s=>s>=90?C.green:s>=70?C.gold:s>=40?C.blue:C.red; const canVerify = state.role==='tracer'||state.role==='admin'; // verdict weight for re-ranking: confirmed rises, wrong sinks const vRank=v=>v==='correct'?1000: v==='relative'? 200 : v==='moved'? -400 : v==='wrong'? -1000 : 0; const vBadge=v=>{ if(v==='correct') return `✓ FIELD-CONFIRMED`; if(v==='wrong') return `✕ WRONG`; if(v==='moved') return `MOVED / NEW OCCUPANT`; if(v==='relative') return `RELATIVE / FAMILY HOUSE`; return ''; }; // ---- NUMBERS ---- const numItems=(q.numbers||[]).map((n,i)=>({n,i,v:c.verify.num[i]||''})) .sort((a,b)=> (b.n.score+vRank(b.v)) - (a.n.score+vRank(a.v)) ); const nums=numItems.map(({n,i,v})=>{const dead=v==='wrong'; return `
${n.score}%
${n.n}
${n.type}
${icon('phone',13,n.score>0&&!dead?'#fff':C.steel)} Call
${vBadge(v)} ${canVerify?`
`:''}
`;}).join(''); // ---- ADDRESSES ---- const addrItems=(q.addresses||[]).map((a,i)=>({a,i,v:c.verify.addr[i]||''})) .sort((x,y)=> (y.a.score+vRank(y.v)) - (x.a.score+vRank(x.v)) ); const addrs=addrItems.map(({a,i,v})=>{const dead=v==='wrong'; return `
${a.score}%
${a.a}
${icon('nav',15,C.blue)}
${vBadge(v)} ${canVerify?`
`:''}
`;}).join(''); // captured real address (field truth) const captured = c.verify.realAddr?`
${icon('pin',13,C.green)}CONFIRMED ON SITE — REAL ADDRESS
${c.verify.realAddr}
GPS ${c.verify.realGps||'—'} · captured by field
`:''; const captureBtn = canVerify?``:''; const leadRow=(x,i,kind,iconN,iconC)=>{const v=x.verdict||'';const vc=v==='hot'?C.gold:v==='dead'?C.steelLite:null; return `
${icon(iconN,13,vc||iconC)}
${v==='hot'?'🔥 ':''}${x.name}
${[x.link||x.role,x.phone,x.note].filter(Boolean).join(' · ')||'—'}
${x.phone?`${icon('phone',13,C.green)}`:''} ${canVerify?` `:''}
`;}; const sortLead=arr=>[...(arr||[])].map((x,i)=>({...x,_i:i})).sort((a,b)=>(a.verdict==='hot'?-1:a.verdict==='dead'?1:0)-(b.verdict==='hot'?-1:b.verdict==='dead'?1:0)); const rels=sortLead(q.relatives).map(r=>leadRow(r,r._i,'relative','users',C.purple)).join(''); const emps=sortLead(q.employers).map(e=>leadRow(e,e._i,'employer','bank',C.steel)).join(''); const defs=(q.defaults||[]).map(d=>`
${icon('wallet',13,C.red)}
${d.who}
${d.status}
${d.amount}
`).join(''); const sect=(title,body,ic,col)=>`
${icon(ic,14,col)}${title}${icon('chev',15,C.steelLite)}
${body}
`; return `
${icon('target',13,C.gold)}CASE INTELLIGENCE — BUREAU DATA
${(q.alerts||[]).map(al=>`
${icon('alert',14,C.red)}
${al.k}: ${al.x}
`).join('')}
${canVerify?'Bureau ranks by confidence. Verify in the field — your verdict beats the score and re-ranks the list.':'Ranked by confidence. Field-confirmed items rise to the top.'}
${sect('Contact numbers (best first)',nums,'phone',C.green)} ${sect('Known addresses',addrs+captured+captureBtn,'pin',C.blue)} ${sect('Possible relatives — leads',rels||'
None on bureau.
','users',C.purple)} ${sect('Employers — trace at work',emps,'bank',C.steel)} ${defs?sect('Credit defaults (context)',defs,'wallet',C.red):''} ${canVerify||state.role==='umvelo'?``:''}
`; } function instructionCard(c){ const ins=c.instruction; if(!ins) return ''; const updates=(ins.updates||[]).map(u=>`
${icon('chat',12,C.blue)}
${u.x}
${u.by} · ${u.t}
`).join(''); return `
${icon('lock',14,C.gold)}Umvelo instruction
LOCKED · ORIGINAL
MOTIVATION
${ins.motivation}
INSTRUCTION
${ins.order}
Received ${ins.received} · this original is never edited.
${updates?`
UPDATES SINCE (${ins.updates.length})
${updates}
`:''}
`; } // ---- team chat channels (with visibility wall) ---- function teamChannels(){ const r=state.role; const ch=[]; // internal: admin + tracers only (NEVER umvelo) if(r==='admin'||r==='tracer') ch.push({k:'internal',label:'Internal team',sub:'Office & tracers only — private',icon:'shield',color:C.navy}); // umvelo: admin + tracers + umvelo ch.push({k:'umvelo',label:'Umvelo channel',sub:'With Umvelo agents',icon:'chat',color:C.purple}); return ch; } function teamChatScreen(){ const chans=teamChannels(); return `

Team chat

Choose a channel

${chans.map(c=>``).join('')} ${state.role!=='umvelo'?`
${icon('lock',13,C.blue)}
The Internal team channel is private to your office and tracers. Umvelo agents cannot see it.
`:''}
`; } function teamChannelChat(){ const chan=state.teamChan||'umvelo'; const meta=teamChannels().find(c=>c.k===chan)||{label:'Chat',sub:''}; // load messages if(API.token && state._chanLoaded!==chan){ state._chanLoaded=chan; API.getTeamChat(chan).then(msgs=>{ state.chanMsgs=(msgs||[]).map(m=>({w:m.author_name,r:m.role,t:new Date(m.created_at).toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'}),x:m.body})); render(); }).catch(e=>{ state.chanMsgs=[]; render(); }); } const msgs=state.chanMsgs||[]; const bub=msgs.map(m=>{const mine=m.w===ROLES[state.role].who||m.r===state.role;const R=ROLES[m.r]||ROLES.admin; return `
${!mine?`
${icon('shield',9,'#fff')}${m.w}
`:''}
${m.x}
${m.t}
`;}).join(''); return `
${aurora()}
${meta.label}
${meta.sub}
${msgs.length?bub:`
No messages yet. Start the conversation.
`}
`; } function chat(msgs,title,sub,isCase){ const bub=msgs.map(m=>{const mine=m.w===ROLES[state.role].who;const R=ROLES[m.r]; return `
${!mine?`
${icon('shield',9,'#fff')}${m.w}
`:''}
${m.x}
${m.t}
`}).join(''); const backBtn = isCase?``:''; return `
${aurora()}
${backBtn}
${title}
${sub}
${msgs.length?bub:`
No messages yet. Start the conversation.
`}
`; } /* ============================ WIRE ============================ */ function wire(){ const rs=document.getElementById('roleSwitch'); if(rs) rs.onclick=roleMenu; const nb=document.getElementById('notifBell'); if(nb) nb.onclick=()=>{state.screen='notifications';render();}; app.querySelectorAll('.intCreate').forEach(b=>{ if(!b.disabled) b.onclick=()=>createCaseFromEmail(b.dataset.id); }); app.querySelectorAll('.intDelete').forEach(b=>b.onclick=async()=>{ if(!window.confirm('Delete this email permanently? The attached documents will be removed from the server. This cannot be undone.'))return; state.intakeItems=(state.intakeItems||[]).filter(x=>x.id!==b.dataset.id); state.intakePending=Math.max(0,(state.intakePending||1)-1); render(); try{ await API.deleteIntake(b.dataset.id); toast('Email deleted'); }catch(e){ toast('Delete failed'); } }); app.querySelectorAll('.intDismiss').forEach(b=>b.onclick=async()=>{ state.intakeItems=(state.intakeItems||[]).filter(x=>x.id!==b.dataset.id); render(); try{ await API.dismissIntake(b.dataset.id); toast('Dismissed'); }catch(e){ toast('Dismiss failed'); } }); const ofu=app.querySelector('.openFollowups'); if(ofu) ofu.onclick=()=>{ state.screen='followups'; state._fuLoading=true; render(); API.listFollowups().then(r=>{state.fuItems=r.items||[];state.fuReminders=r.reminders||[];state.fuOverdue=(r.items||[]).filter(i=>i.overdue).length;state._fuLoading=false;render();}) .catch(()=>{state.fuItems=[];state._fuLoading=false;toast('Could not load follow-ups');render();}); }; app.querySelectorAll('.fuCase').forEach(b=>b.onclick=async()=>{ const id=b.dataset.id; state.caseId=id; state.screen='case'; render(); if(API.token){try{const full=await API.loadCase(id);const k=CASES.findIndex(x=>x.id===id);if(k>-1)CASES[k]=full;else CASES.push(full);if(state.caseId===id)render();}catch(e){}} }); app.querySelectorAll('.remDone').forEach(b=>b.onclick=async()=>{ state.fuReminders=(state.fuReminders||[]).filter(r=>r.id!==b.dataset.id); render(); try{ await API.doneReminder(b.dataset.id); toast('Reminder cleared'); }catch(e){} }); const sw=app.querySelector('.setWaiting'); if(sw) sw.onclick=openWaiting; const oint=app.querySelector('.openIntake'); if(oint) oint.onclick=()=>{ state.screen='intake'; state._intakeLoading=true; render(); API.listIntake('pending').then(r=>{state.intakeItems=r.items||[];state.intakePending=r.pendingCount||0;state._intakeLoading=false;render();}) .catch(()=>{state.intakeItems=[];state._intakeLoading=false;toast('Could not load the mailbox');render();}); }; // count-up hero figures (runs once per element) app.querySelectorAll('.countup').forEach(el=>{ if(el.dataset.done) return; el.dataset.done='1'; const cents=+el.dataset.cents||0; if(!cents) return; const R=v=>'R'+(v/100).toLocaleString('en-ZA',{minimumFractionDigits:2}); const t0=performance.now(), dur=700; const tick=t=>{const p=Math.min(1,(t-t0)/dur);const e=1-Math.pow(1-p,3);el.textContent=R(Math.round(cents*e));if(p<1)requestAnimationFrame(tick);}; requestAnimationFrame(tick); }); const nra=app.querySelector('.notifReadAll'); if(nra) nra.onclick=async()=>{(state.notifItems||[]).forEach(n=>n.read=true);state.notifUnread=0;render();if(API.token){API.readAllNotifications().catch(()=>{});}}; app.querySelectorAll('.notifItem').forEach(b=>b.onclick=async()=>{ const n=(state.notifItems||[]).find(x=>x.id===b.dataset.id); if(n&&!n.read){n.read=true;state.notifUnread=Math.max(0,(state.notifUnread||1)-1);if(API.token)API.readNotification(n.id).catch(()=>{});} const cid=b.dataset.caseid; if(cid){ state.caseId=cid; state.screen='case'; render(); if(API.token){ try{ const full=await API.loadCase(cid); const k=CASES.findIndex(x=>x.id===cid); if(k>-1){CASES[k]=full;}else{CASES.push(full);} if(state.caseId===cid)render(); }catch(e){} } } else render(); }); app.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{state.screen=b.dataset.tab;if(b.dataset.tab==='dashboard'){state.dtab='overview';state.btab='all';}render();}); app.querySelectorAll('[data-case]').forEach(b=>b.onclick=async ()=>{ const id=b.dataset.case; state.caseId=id; state.screen='case';render(); // show instantly from cache if(API.token){ try{ const full=await API.loadCase(id); const k=CASES.findIndex(x=>x.id===id); if(k>-1){CASES[k]=full; if(state.caseId===id) render();} }catch(e){} } }); app.querySelectorAll('.backbtn').forEach(b=>b.onclick=()=>{state.screen=b.dataset.to;render();}); const gc=app.querySelector('.gotochat'); if(gc) gc.onclick=()=>{state.screen='casechat';render();}; app.querySelectorAll('.teamChan').forEach(b=>b.onclick=()=>{state.teamChan=b.dataset.ch;state._chanLoaded=null;state.chanMsgs=null;state.screen='teamchannel';render();}); const tcl=app.querySelector('.tochanlist'); if(tcl) tcl.onclick=()=>{state.screen='chat';render();}; const chsend=app.querySelector('.chansend'); const chinput=app.querySelector('#chaninput'); if(chsend&&chinput){ const send=async()=>{ const body=chinput.value.trim(); if(!body)return; const chan=state.teamChan||'umvelo'; state.chanMsgs=state.chanMsgs||[]; state.chanMsgs.push({w:ROLES[state.role].who,r:state.role,t:'now',x:body}); chinput.value=''; render(); if(API.token){ try{ await API.postTeamChat(chan,body); }catch(e){ toast('Send failed'); } } }; chsend.onclick=send; chinput.onkeydown=e=>{if(e.key==='Enter')send();}; const sc2=document.getElementById('chatscroll'); if(sc2) sc2.scrollTop=sc2.scrollHeight; } const gf=app.querySelector('.gotofield'); if(gf) gf.onclick=()=>{state.screen='field';render();}; const nxa=app.querySelector('.nxAction'); if(nxa) nxa.onclick=()=>{ const k=nxa.dataset.k, v=nxa.dataset.v||''; if(k==='call'){ const tel=v.replace(/\s/g,''); if(/^[\d+]/.test(tel)) window.location.href='tel:'+tel; else toast('No number on file'); } else { if(v&&v!=='No contact details on file') window.open('https://www.google.com/maps/search/?api=1&query='+encodeURIComponent(v+', South Africa'),'_blank'); else toast('No address on file'); } }; const fst=app.querySelector('.fieldSettle'); if(fst) fst.onclick=openSettle; const ub=app.querySelector('.unblock'); if(ub) ub.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);c.stage='tracing';c.blocked=null;c.thread.push({r:'umvelo',w:'B. Thorpe',t:'now',k:'resolve',x:'Warrant issued. Tracer can proceed.'});toast('Case unblocked');render();}; const fu=app.querySelector('.fieldupdate'); if(fu) fu.onclick=()=>openFieldUpdate(); const sos=app.querySelector('.sosBtn'); if(sos) sos.onclick=openSOS; const ca=app.querySelector('.confirmAssign'); if(ca) ca.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);c.assignStatus='confirmed';c.thread.push({r:'admin',w:'Office',t:'now',k:'allocate',x:`Assignment to ${c.tracer} confirmed. Awaiting acceptance.`});toast('Assignment confirmed');render();}; const aa=app.querySelector('.acceptAssign'); if(aa) aa.onclick=async()=>{const c=CASES.find(x=>x.id===state.caseId);c.assignStatus='accepted';c.thread.push({r:'tracer',w:c.tracer,t:'now',k:'update',x:'Case accepted. Starting work.'});toast('Case accepted');render();if(API.token){try{await API.acceptCase(c.id);}catch(e){toast('Saved locally — sync failed');}}}; const rb=app.querySelector('.reassignBtn'); if(rb) rb.onclick=openReassign; const ld=app.querySelector('.logDispo'); if(ld) ld.onclick=openDispo; const ld2=app.querySelector('.logDispo2'); if(ld2) ld2.onclick=openDispo; const ldc=app.querySelector('.logDispoCase'); if(ldc) ldc.onclick=openDispo; const aiu=app.querySelector('.addInsUpdate'); if(aiu) aiu.onclick=openInsUpdate; const adi=app.querySelector('.addIntel'); if(adi) adi.onclick=openAddIntel; app.querySelectorAll('.leadV').forEach(b=>b.onclick=async()=>{const c=CASES.find(x=>x.id===state.caseId);const arr=b.dataset.kind==='relative'?c.intel.relatives:c.intel.employers;const it=arr[+b.dataset.i];if(!it)return;const nv=it.verdict===b.dataset.v?null:b.dataset.v;it.verdict=nv;render();toast(nv==='hot'?'🔥 Marked HOT lead':nv==='dead'?'Marked dead end':'Cleared');if(API.token&&it._id){API.setLead(b.dataset.kind,it._id,{verdict:nv||''}).catch(()=>toast('Sync failed'));}}); app.querySelectorAll('.leadN').forEach(b=>b.onclick=()=>openLeadNote(b.dataset.kind,+b.dataset.i)); const spot=app.querySelector('.spotVehicle'); if(spot) spot.onclick=openSpotted; app.querySelectorAll('.vnum').forEach(b=>b.onclick=async ()=>{const c=CASES.find(x=>x.id===state.caseId);c.verify=c.verify||{num:{},addr:{}};const i=b.dataset.i;const newV=c.verify.num[i]===b.dataset.v?'':b.dataset.v;c.verify.num[i]=newV;const n=c.intel.numbers[i];const who=ROLES[state.role]?ROLES[state.role].who:'Field';if(c.verify.num[i])c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Number ${n.n} field-verified: ${b.dataset.v==='correct'?'WORKS':'WRONG'}.`});toast('Number '+(b.dataset.v==='correct'?'confirmed':'flagged wrong'));render();if(API.token&&n._id){try{await API.verifyNumber(n._id,newV||null);}catch(e){toast('Saved locally — sync failed');}}}); app.querySelectorAll('.vaddr').forEach(b=>b.onclick=async ()=>{const c=CASES.find(x=>x.id===state.caseId);c.verify=c.verify||{num:{},addr:{}};const i=b.dataset.i;const prev=c.verify.addr[i];const newV=prev===b.dataset.v?'':b.dataset.v;c.verify.addr[i]=newV;const a=c.intel.addresses[i];const who=ROLES[state.role]?ROLES[state.role].who:'Field';const lbl=b.dataset.v==='correct'?'DEBTOR HERE':b.dataset.v==='relative'?'RELATIVE / family house':b.dataset.v==='moved'?'MOVED / new occupant':'WRONG ADDRESS';if(c.verify.addr[i]){c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Address field-verified: ${a.a} — ${lbl}.`});if(b.dataset.v==='wrong'){c.chat.push({w:'System',r:'admin',t:'now',x:`📍 ${who} flagged an address WRONG for ${c.debtor}. Office to chase a fresh address.`});}if(b.dataset.v==='relative'){c.chat.push({w:'System',r:'admin',t:'now',x:`👪 ${who} confirmed an address as a RELATIVE/family house for ${c.debtor} — debtor doesn't stay there, but it's a live family link.`});}}toast('Address: '+lbl.toLowerCase());render();if(API.token&&a._id){try{await API.verifyAddress(a._id,newV||null,c.id);}catch(e){toast('Saved locally — sync failed');}}}); const capA=app.querySelector('.captureAddr'); if(capA) capA.onclick=openCaptureAddr; const sb=app.querySelector('.settleBtn'); if(sb) sb.onclick=openSettle; const ivb=app.querySelector('.invoiceBtn'); if(ivb) ivb.onclick=openInvoice; const ufb=app.querySelector('.umvFeeBtn'); if(ufb) ufb.onclick=openUmveloFee; const ipdf=app.querySelector('.invoicePdf'); if(ipdf) ipdf.onclick=()=>{ const c=CASES.find(x=>x.id===state.caseId); const inv=c._invoice||{fee_cents:c.arrearsCents,vat_applies:false,vat_cents:0,umvelo_cents:c.arrearsCents,id:c.id}; if(!state.business&&API.token){ API.getBusiness().then(bz=>{state.business=bz;generateInvoicePDF(inv,c,bz);}).catch(()=>generateInvoicePDF(inv,c,{})); } else generateInvoicePDF(inv,c,state.business||{}); }; const inA=app.querySelector('.impNotice'); if(inA) inA.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);c.impound.noticeAttached=!c.impound.noticeAttached;if(c.impound.noticeAttached){c.files=c.files||[];c.files.push({n:'ImpoundNotice_'+c.debtor.replace(/\s/g,'')+'.jpg',t:'photo',by:ROLES[state.role].who,d:'now'});toast('Impound notice attached to fee record');}render();}; const iR=app.querySelector('.impRaise'); if(iR) iR.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);c.impound.status='raised';c.thread.push({r:state.role,w:ROLES[state.role].who,t:'now',k:'block',x:`Impound release fee R${c.impound.fee.toLocaleString()} raised to Umvelo. Case handed back — awaiting ABSA payment. Notice attached.`});c.chat.push({w:'System',r:'admin',t:'now',x:`💰 Impound fee R${c.impound.fee.toLocaleString()} raised to Umvelo for ${c.debtor}. Umvelo to follow up so ABSA pays. Case handed back.`});toast('Raised to Umvelo · case handed back');render();}; const ip=app.querySelector('.impPay'); if(ip) ip.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);c.impound.feePaid=true;c.impound.status='paid';c.files=c.files||[];c.files.push({n:'ProofOfPayment_Fines_'+c.debtor.replace(/\s/g,'')+'.pdf',t:'other',by:ROLES[state.role].who,d:'now'});c.thread.push({r:state.role,w:ROLES[state.role].who,t:'now',k:'resolve',x:`ABSA paid the impound fee — proof of payment attached to the fee record. Case resumes for release.`});c.chat.push({w:'System',r:'admin',t:'now',x:`✅ ABSA paid impound fee for ${c.debtor}. Proof on file. Proceed with release document pack.`});toast('Payment recorded · case resumes');render();}; app.querySelectorAll('.impDoc').forEach(b=>b.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);c.impound.releaseDocs=c.impound.releaseDocs||{};const i=+b.dataset.i;c.impound.releaseDocs[i]=!c.impound.releaseDocs[i];const nm=(POUNDS[c.impound.pound]||POUNDS.ndabeni).releaseDocs[i];if(c.impound.releaseDocs[i]){c.files=c.files||[];c.files.push({n:nm.replace(/[^A-Za-z]/g,'')+'_'+c.debtor.replace(/\s/g,'')+'.pdf',t:'other',by:ROLES[state.role].who,d:'now'});toast(nm+' attached');}render();}); app.querySelectorAll('.fileRow').forEach(b=>b.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);const f=c.files[+b.dataset.i];if(f.summary&&c.handoverSummary){openReceipt(c);}else{openFile(f);}}); const af2=app.querySelector('.addFile'); if(af2) af2.onclick=openAttach; const jf=app.querySelector('.jumpFiles'); if(jf) jf.onclick=()=>{const el=[...app.querySelectorAll('.arch')].find(e=>e.textContent.includes('CASE FILES'));if(el)el.scrollIntoView({behavior:'smooth',block:'start'});}; const mgc=app.querySelector('.manageCase'); if(mgc) mgc.onclick=openCaseManage; const atc=app.querySelector('.atChip'); if(atc) atc.onclick=()=>toast(atc.dataset.d); const cr=app.querySelector('.caseReport'); if(cr) cr.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);if(!state.business&&API.token){API.getBusiness().then(bz=>{state.business=bz;generateCaseReportPDF(c);}).catch(()=>generateCaseReportPDF(c));}else generateCaseReportPDF(c);}; // handover wizard const sh=app.querySelector('.startHandover'); if(sh) sh.onclick=()=>{hw=null;state.screen='handover';render();}; const lv=app.querySelector('.logVisit'); if(lv) lv.onclick=openVisit; const hb=app.querySelector('.hwBack'); if(hb) hb.onclick=()=>{if(hw.step>1){hw.step--;render();}else{hw=null;state.screen='field';render();}}; const hn=app.querySelector('.hwNext'); if(hn) hn.onclick=()=>{ if(hw.step===3&&hw.tow===null){toast('Choose driven or towed');return;} if(hw.step===3){hw.include.towing=hw.tow;} hw.step++;render(); }; app.querySelectorAll('.insOpt').forEach(b=>b.onclick=()=>{hw.inspect[INSPECT_QUICK[+b.dataset.it]]=b.dataset.v;render();}); const iff=app.querySelector('.insFull'); if(iff) iff.onclick=()=>{hw.inspectFull=!hw.inspectFull;render();}; // section-based inspection app.querySelectorAll('.secPhoto').forEach(b=>b.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);snapPhoto(c&&c.id,'Inspection_'+b.dataset.k,ok=>{if(ok){const s=hw.sections[b.dataset.k];if(s.photos<1)s.photos=1;render();}});}); app.querySelectorAll('.secPhotoAdd').forEach(b=>b.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);snapPhoto(c&&c.id,'Inspection_'+b.dataset.k,ok=>{if(ok){const s=hw.sections[b.dataset.k];if(s.photos<6)s.photos++;render();}});}); app.querySelectorAll('.secCond').forEach(b=>b.onclick=()=>{hw.sections[b.dataset.k].cond=b.dataset.c;render();}); app.querySelectorAll('.secDamage').forEach(b=>b.onclick=()=>{const s=hw.sections[b.dataset.k];s.damage=!s.damage;if(s.damage&&s.cond!=='Bad')s.cond='Bad';render();}); app.querySelectorAll('.secComment').forEach(inp=>inp.oninput=e=>{hw.sections[inp.dataset.k].comment=e.target.value;}); const bf=app.querySelector('.bulkFair'); if(bf) bf.onclick=()=>{INSPECT_SECTIONS.forEach(s=>{hw.sections[s.k].cond='Fair';hw.sections[s.k].damage=false;});toast('All sections set to Fair');render();}; const aif=app.querySelector('.aiFill'); if(aif) aif.onclick=()=>{ // MOCK the AI: photograph all sections, auto-set condition (mostly Fair, flag a plausible Bad) INSPECT_SECTIONS.forEach((s,i)=>{const st=hw.sections[s.k];if(st.photos<1)st.photos=2;st.cond='Fair';}); // simulate the AI catching one damaged area hw.sections.front.cond='Bad'; hw.sections.front.damage=true; hw.sections.front.comment='AI detected: scratch/dent on front bumper'; toast('AI read the photos — conditions filled'); render(); }; const bl=app.querySelector('.belong'); if(bl) bl.onclick=()=>{hw.belongings=!hw.belongings;render();}; app.querySelectorAll('.addExp').forEach(b=>b.onclick=()=>{const t=b.dataset.t; moneySheet({title:t+' expense',sub:'Enter what was actually paid. Attach the receipt next — no receipt, no refund.',askNote:true,notePh:'e.g. Engen N2, 23L',confirm:'Add '+t},async d=>{ hw.expenses.push({type:t,amount:d.rands,note:d.note,receipt:false,receiptFileId:null}); toast(t+' R'+d.rands+' added'); render(); });}); app.querySelectorAll('.expRcpt').forEach(b=>b.onclick=()=>{const i=+b.dataset.i;const c=CASES.find(x=>x.id===state.caseId); snapReceipt(c&&c.id,fid=>{ if(fid){hw.expenses[i].receipt=true;hw.expenses[i].receiptFileId=fid;render();} });}); app.querySelectorAll('.towPick').forEach(b=>b.onclick=()=>{hw.tow=b.dataset.v==='true';render();}); app.querySelectorAll('.docTog').forEach(b=>b.onclick=()=>{if(b.disabled)return;hw.include[b.dataset.k]=!hw.include[b.dataset.k];render();}); app.querySelectorAll('.signBtn').forEach(b=>b.onclick=()=>{const key=b.dataset.k+'_'+b.dataset.s; const who=b.dataset.s==='client'?'Client':b.dataset.s==='tracer'?'Tracer':b.dataset.s; signaturePad(who+' signature','Signed on the device — embedded as real ink on the generated documents, with GPS and time.',!!hw.signed[key],url=>{hw.signed[key]=url;render();});}); const wv=app.querySelector('.wesbankVal'); if(wv) wv.onclick=()=>{hw.wesbankValidated=true;toast('WesBank validation recorded');render();}; const cp=app.querySelector('.capturePhoto'); if(cp) cp.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);snapPhoto(c&&c.id,'Handover_client_vehicle',ok=>{if(ok){hw.photo=true;render();}});}; const km=app.querySelector('.hwKm'); if(km) km.oninput=e=>{hw.km=e.target.value;}; const kmp=app.querySelector('.hwKmPhoto'); if(kmp) kmp.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);snapPhoto(c&&c.id,'Odometer_dash',ok=>{if(ok){hw.kmPhoto=true;render();}});}; app.querySelectorAll('.hwFuel').forEach(b=>b.onclick=()=>{hw.fuel=b.dataset.f;render();}); app.querySelectorAll('.hwVphoto').forEach(b=>b.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);snapPhoto(c&&c.id,'Handover_'+b.dataset.p,ok=>{if(ok){hw.photos=hw.photos||{};hw.photos[b.dataset.p]=true;render();}});}); const tw=app.querySelector('.toggleWitness'); if(tw) tw.onclick=()=>{hw.witness=!hw.witness;render();}; const wn=app.querySelector('.witnessName'); if(wn) wn.oninput=e=>{hw.witnessName=e.target.value;}; const tr=app.querySelector('.toggleRefused'); if(tr) tr.onclick=()=>{hw.refused=!hw.refused;if(hw.refused){/* clear client sigs */Object.keys(hw.signed).forEach(k=>{if(k.endsWith('_client'))delete hw.signed[k];});}render();}; const rn=app.querySelector('.refusedNote'); if(rn) rn.oninput=e=>{hw.refusedNote=e.target.value;}; const hc=app.querySelector('.hwComplete'); if(hc) hc.onclick=completeHandover; const nc=app.querySelector('.newCase'); if(nc) nc.onclick=()=>{draft={step:1};state.screen='addcase';render();}; const om=app.querySelector('.openMap'); if(om) om.onclick=()=>{state.mapLocs=null;state.screen='map';render();}; const af=app.querySelector('.attnFilter'); if(af) af.onclick=()=>{state.attnOnly=!state.attnOnly;render();}; const bb=app.querySelector('.backboard'); if(bb) bb.onclick=()=>{draft=null;state.screen='board';render();}; app.querySelectorAll('.slotPick').forEach(btn=>{ btn.onclick=()=>{ const inp=app.querySelector('.slotInput[data-slot="'+btn.dataset.slot+'"]'); if(inp) inp.click(); }; }); app.querySelectorAll('.slotInput').forEach(inp=>{ inp.onchange=()=>{ const files=Array.from(inp.files||[]); if(!files.length) return; const slot=inp.dataset.slot; draft=draft||{step:1}; draft.files=draft.files||[]; draft._uploads=draft._uploads||[]; // for single-file slots, replace any existing of that type if(slot!=='other'){ draft.files=draft.files.filter(f=>f.t!==slot); draft._uploads=draft._uploads.filter(u=>u._slot!==slot); } // cap "other" at 2 if(slot==='other'){ const existing=draft.files.filter(f=>f.t==='other').length; if(existing+files.length>2){toast('Up to 2 extra documents');return;} } files.forEach(f=>{ draft.files.push({n:f.name,t:slot,by:ROLES[state.role].who+' ('+ROLES[state.role].label+')',d:'now'}); const uf=f; uf._slot=slot; draft._uploads.push(uf); }); toast(files.length+' file'+(files.length>1?'s':'')+' added to '+(FILE_TYPES[slot]?FILE_TYPES[slot].label:slot)); render(); // extract from the core documents: instruction = case facts; XDS/CPB = intelligence const pdf=files.find(f=>/\.pdf$/i.test(f.name)||f.type==='application/pdf'); if((slot==='instruction'||slot==='xds'||slot==='cpb') && pdf && API.token){ toast('Reading '+(FILE_TYPES[slot]?FILE_TYPES[slot].label:'document')+' — extracting'+(slot!=='instruction'?' (scanned reports take ~15s)':'')+'…'); (async()=>{ try{ const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(rd.result.split(',')[1]);rd.onerror=rej;rd.readAsDataURL(pdf);}); const r=await API.extractPdf(b64, slot==='instruction'?'instruction':'bureau'); if(r&&r.ocrNeeded){ toast('📷 This '+(FILE_TYPES[slot]?FILE_TYPES[slot].label:'report')+' is a scanned image — kept on the case, but details must be typed in'); render(); return; } const f=r&&r.fields||{}; draft.extract=draft.extract||{numbers:[],addresses:[],relatives:[],employers:[],alerts:[]}; const ex=draft.extract; // merge intelligence (dedupe numbers by digits, addresses by text) const numKey=n=>(n.number||'').replace(/\D/g,''); (f.numbers||[]).forEach(n=>{const k=numKey(n);if(k&&!ex.numbers.some(x=>numKey(x)===k))ex.numbers.push(n);else{const hit=ex.numbers.find(x=>numKey(x)===k);if(hit&&n.score&&!hit.score){hit.score=n.score;hit.label=n.label||hit.label;}}}); (f.addresses||[]).forEach(a=>{const t=typeof a==='string'?{address:a,score:null}:a;const k=(t.address||'').toLowerCase().replace(/\s+/g,' ');if(k.length>8&&!ex.addresses.some(x=>(x.address||'').toLowerCase().replace(/\s+/g,' ')===k))ex.addresses.push(t);}); (f.relatives||[]).forEach(rl=>{if(!ex.relatives.some(x=>x.name===rl.name))ex.relatives.push(rl);}); (f.employers||[]).forEach(e2=>{if(!ex.employers.some(x=>x.name===e2.name))ex.employers.push(e2);}); (f.alerts||[]).forEach(al=>{if(!ex.alerts.some(x=>x.kind===al.kind))ex.alerts.push(al);}); if(slot==='instruction'){ // case facts come from the instruction const bank=(f.account_code||'').toUpperCase().startsWith('BMW')?'BMW Finance':'ABSA'; draft.data=Object.assign(draft.data||{},{ debtor:((f.debtor_first_name||'')+' '+(f.debtor_surname||f.debtor_name||'')).trim()||f.debtor_name||'', accnum:f.acc_number||'', idnum:f.id_number||'', asset:f.asset||'', reg:f.v_reg||'', engine:f.v_engine||'', chassis:f.v_chassis||'', ref:f.ref||'', bank:f.ref?bank:'', arrears:f.arrears_cents?(f.arrears_cents/100).toFixed(2):'', contact:(ex.numbers[0]&&ex.numbers[0].number)||'', addr:(ex.addresses[0]&&ex.addresses[0].address)||'', task:f.task||(draft.data&&draft.data.task)||'', tracer:(draft.data&&draft.data.tracer)||'' }); Object.assign(ex,{_facts:f}); toast('✓ Instruction read — add the bureau reports too, then Continue'); } else if(draft.data){ // bureau docs can fill gaps the instruction left if(!draft.data.contact&&ex.numbers[0])draft.data.contact=ex.numbers[0].number; if(!draft.data.addr&&ex.addresses[0])draft.data.addr=ex.addresses[0].address; } const s=[ex.numbers.length&&ex.numbers.length+' 📞',ex.addresses.length&&ex.addresses.length+' 🏠',ex.relatives.length&&ex.relatives.length+' 👥',ex.employers.length&&ex.employers.length+' 🏢',ex.alerts.length&&ex.alerts.length+' ⚠'].filter(Boolean).join(' · '); toast((r.ocrUsed?'✓ Scanned report read by OCR — ':'✓ Extracted — ')+'case intelligence so far: '+(s||'building…')); render(); }catch(e){ toast('Could not read that PDF — details stay manual'); } })(); } }; }); const tm=app.querySelector('.toManual'); if(tm) tm.onclick=()=>{ draft=draft||{}; draft.step=2; draft.data=draft.data||{debtor:'',accnum:'',idnum:'',asset:'',reg:'',engine:'',chassis:'',ref:'',bank:'',arrears:'',contact:'',addr:'',task:'',tracer:''}; render(); }; const cc=app.querySelector('.createCase'); if(cc) cc.onclick=async ()=>{ // guard against double-taps: the create is async, so lock the button immediately if(cc.dataset.busy){ return; } cc.dataset.busy='1'; cc.disabled=true; cc.style.opacity='.6'; cc.textContent='Creating…'; // gather field values const vals={}; app.querySelectorAll('.af').forEach(i=>vals[i.dataset.k]=i.value.trim()); const newId = vals.ref || ('26/6/NEW/'+Math.floor(Math.random()*9000)); const newCase={ id:newId, type:(vals.ref||'').split('/')[2]||'—', debtor:vals.debtor||'New debtor', bank:vals.bank||'ABSA', asset:vals.asset||'—', reg:vals.reg||'—', vehicle:{make:(vals.asset||'').split(' ')[1]||'—',model:(vals.asset||'').split(' ').slice(2).join(' ')||vals.asset,year:(vals.asset||'').split(' ')[0]||'',colour:'—',engine:vals.engine||'verify',chassis:vals.chassis||'verify'}, agent:ROLES[state.role].who, tracer:vals.tracer||null, assignStatus:vals.tracer?'auto':'placed', assignedBy:vals.tracer?'Umvelo instruction':'—', stage:vals.tracer?'tracing':'placed', blocked:null, priority:'high', task:vals.task||'Trace and confirm', amount:1850, region:'wc', centreOverride:null, rule:vals.task||'', files:(draft&&draft.files)?draft.files:[], contacts:{collector:['Umvelo collector','011 951 0010'],saps:['Nearest SAPS','10111'],sheriff:['Sheriff','—'],tow:['Umvelo Towing','011 951 0010']}, steps:[vals.contact?{k:'call',v:vals.contact,m:'From instruction'}:{k:'drive',v:vals.addr||'—',m:'Home address'}].concat(vals.addr?[{k:'drive',v:vals.addr,m:'Home address'}]:[]), sighting:null, instruction:{received:'now',motivation:(vals.notes||'')+(vals.arrears?(' Arrears '+vals.arrears+'.'):''),order:vals.task||'',updates:[]}, thread:[{r:state.role,w:ROLES[state.role].who,t:'now',k:'place',x:`Case created from uploaded instruction${vals.tracer?' — auto-assigned to '+vals.tracer:''}.`}], chat:[] }; // persist to live API if(API.token){ const arrCents=vals.arrears?Math.round(parseFloat(vals.arrears.replace(/[^0-9.]/g,''))*100):null; const parts=(vals.asset||'').split(' '); try{ const r=await API.addCase({ ref:vals.ref, acc_number:vals.accnum||null, account_code:(vals.ref||'').split('/')[2]||null, bank:vals.bank||'ABSA', debtor:vals.debtor, arrears_cents:arrCents, v_year:parts[0]||null, v_make:parts[1]||null, v_model:parts.slice(2).join(' ')||null, v_reg:vals.reg||null, v_engine:vals.engine||null, v_chassis:vals.chassis||null, task:vals.task, motivation:vals.notes||null, order:vals.task||null }); if(r&&r.id){ // upload any attached documents to the new case if(draft&&draft._uploads&&draft._uploads.length){ for(const f of draft._uploads){ try{ const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(rd.result.split(',')[1]);rd.onerror=rej;rd.readAsDataURL(f);}); const kind=f._slot||'other'; await API.post('/files/'+r.id+'/upload',{filename:f.name,kind,dataBase64:b64}); }catch(e){/* keep going */} } } if(r&&r.duplicate){ cc.dataset.busy=''; cc.disabled=false; cc.style.opacity='1'; draft=null; state.caseId=r.id; state.screen='case'; toast('⚠ '+(r.message||'This account already has an open case')+' — opening it'); CASES=await API.loadCases(); render(); return; } // seed FULL case intelligence from all extracted documents if(draft&&draft.extract){ const ex=draft.extract, fx=ex._facts||{}; for(const n of (ex.numbers||[]).slice(0,10)){ try{ await API.addIntel(r.id,'number',n.number,n.label||'From documents',n.score); }catch(e){} } for(const a of (ex.addresses||[]).slice(0,6)){ try{ await API.addIntel(r.id,'address',(a.address||a)+(a.date?' (seen '+a.date+')':''),'',a.score); }catch(e){} } for(const rl of (ex.relatives||[]).slice(0,8)){ try{ await API.addIntel(r.id,'relative',rl.name,rl.link||''); }catch(e){} } for(const em of (ex.employers||[]).slice(0,5)){ try{ await API.addIntel(r.id,'employer',em.name,em.role||''); }catch(e){} } if(fx.employer){ try{ await API.addIntel(r.id,'employer',fx.employer,(fx.employer_tel?('Tel '+fx.employer_tel+' · '):'')+'From instruction'); }catch(e){} } for(const al of (ex.alerts||[]).slice(0,5)){ try{ await API.addIntel(r.id,'alert',al.kind,al.body||''); }catch(e){} } if(/^Yes$/i.test(fx.sec129||'')){ try{ await API.addIntel(r.id,'alert','Sec 129 served',fx.sec129_date?('Date '+fx.sec129_date):'Per instruction'); }catch(e){} } const fin=[]; if(fx.collectable_cents) fin.push('Collectable R'+(fx.collectable_cents/100).toLocaleString('en-ZA',{minimumFractionDigits:2})); if(fx.instalment_cents) fin.push('Instalment R'+(fx.instalment_cents/100).toLocaleString('en-ZA',{minimumFractionDigits:2})); if(fx.paid_cents!=null) fin.push('Paid to date R'+(fx.paid_cents/100).toLocaleString('en-ZA',{minimumFractionDigits:2})); if(fx.instalments_due) fin.push(fx.instalments_due+' instalment'+(fx.instalments_due>1?'s':'')+' due'); if(fx.licensed_until) fin.push('Vehicle licensed until '+fx.licensed_until); if(fx.received_on||fx.expires_on) fin.push('Mandate '+(fx.received_on||'?')+' → '+(fx.expires_on||'?')); if(fin.length){ try{ await API.instructionUpdate(r.id,'📄 From the instruction: '+fin.join(' · ')); }catch(e){} } const inv=[]; if((ex.emails||[]).length) inv.push('Email '+ex.emails.join(', ')); if(ex.marital) inv.push(ex.marital); if(ex.dob) inv.push('DOB '+ex.dob); const caseIds=[fx.v_chassis,fx.v_reg].filter(Boolean).map(s=>s.replace(/\s/g,'').toUpperCase()); const otherV=(ex.vehicles||[]).filter(v=>!caseIds.includes(v.replace(/\s/g,'').toUpperCase())); if(otherV.length) inv.push('Other vehicle links: '+otherV.join(', ')); if(inv.length){ try{ await API.instructionUpdate(r.id,'🔎 Bureau investigative extras: '+inv.join(' · ')); }catch(e){} } for(const v of otherV.slice(0,2)){ try{ await API.addIntel(r.id,'alert','Other vehicle linked',v); }catch(e){} } } if(draft&&draft._fromEmail){ try{ await API.linkIntake(draft._fromEmail,r.id); state.intakeItems=(state.intakeItems||[]).filter(x=>x.id!==draft._fromEmail); state.intakePending=Math.max(0,(state.intakePending||1)-1);}catch(e){} } CASES=await API.loadCases(); draft=null; state.caseId=r.id; state.screen='case'; toast('Case created'); render(); return; } }catch(e){ toast('Saved locally — sync failed'); } } CASES.unshift(newCase); draft=null; state.caseId=newId; state.screen='case'; toast('Case created'); render(); }; app.querySelectorAll('.nudge').forEach(b=>b.onclick=()=>{const c=CASES.find(x=>x.id===b.dataset.id);c.chat.push({w:'System',r:'admin',t:'now',x:`🔔 Reminder sent to Umvelo: still awaiting ${BLOCKS[c.blocked]?BLOCKS[c.blocked].label:'action'} on ${c.debtor}.`});toast('Umvelo nudged');}); app.querySelectorAll('.dsub').forEach(b=>b.onclick=()=>{state.dtab=b.dataset.t;render();}); app.querySelectorAll('.btab').forEach(b=>b.onclick=()=>{state.btab=b.dataset.t;render();}); const dqR=app.querySelector('.dqRefresh'); if(dqR) dqR.onclick=e=>{e.stopPropagation();state._dq=undefined;render();}; const dqM=app.querySelector('.dqMore'); if(dqM) dqM.onclick=()=>{state._dqOpen=!state._dqOpen;render();}; // ---- money wiring ---- const invalidateMoney=()=>{state._recexp=null;state._pay=null;if(state.caseId&&state._exp)state._exp[state.caseId]=undefined;}; const eAdd=app.querySelector('.expAdd'); if(eAdd) eAdd.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId);if(!c)return; moneySheet({title:'Log a recovery expense',sub:c.debtor+' — what did it cost to move this car? You will attach the receipt next.',chips:['Fuel','Tolls','Battery','Tyre','Jump-start','Towing','Other'],askNote:true,notePh:'Where / what for',confirm:'Log expense'},async d=>{ const row=await API.addExpense(c.id,{kind:d.kind,amountCents:d.cents,note:d.note}); invalidateMoney(); toast(d.kind+' '+centsR(d.cents)+' logged'); render(); snapReceipt(c.id,async fid=>{ if(fid){ try{await API.expenseReceipt(row.id,fid);}catch(e){} invalidateMoney(); render(); } else { toast('No receipt yet — required before approval'); } }); });}; app.querySelectorAll('.expAttach').forEach(b=>b.onclick=()=>{const c=CASES.find(x=>x.id===state.caseId); snapReceipt(c&&c.id,async fid=>{ if(!fid)return; try{await API.expenseReceipt(b.dataset.id,fid);invalidateMoney();toast('Receipt attached');render();}catch(e){toast(e.message);} });}); app.querySelectorAll('.expOk').forEach(b=>b.onclick=async()=>{try{await API.approveExpense(b.dataset.id,true);invalidateMoney();toast('Approved — bill it to Umvelo');render();}catch(e){toast(e.message||'Failed');}}); app.querySelectorAll('.expNo').forEach(b=>b.onclick=()=>{ moneySheet({title:'Reject expense',sub:'Say why — the tracer sees this.',askAmount:false,askNote:true,notePh:'Reason',confirm:'Reject'},async d=>{ await API.approveExpense(b.dataset.id,false,d.note); invalidateMoney(); toast('Rejected'); render(); });}); app.querySelectorAll('.expBill').forEach(b=>b.onclick=()=>{ moneySheet({title:'Bill to Umvelo',sub:'Amount going on the Umvelo invoice. Defaults to what was spent.',defAmount:(Number(b.dataset.a)/100).toFixed(2),confirm:'Mark billed'},async d=>{ await API.billExpense(b.dataset.id,d.cents); invalidateMoney(); toast('Marked billed'); render(); });}); app.querySelectorAll('.expRecover').forEach(b=>b.onclick=()=>{ moneySheet({title:'What did Umvelo pay?',sub:'Billed '+centsR(b.dataset.a)+'. Enter the actual amount received — any gap becomes shortfall.',defAmount:(Number(b.dataset.a)/100).toFixed(2),confirm:'Capture'},async d=>{ const r=await API.recoverExpense(b.dataset.id,d.cents); invalidateMoney(); toast(r.shortfallCents>0?'Captured — shortfall '+centsR(r.shortfallCents):'Recovered in full'); render(); });}); const advNew=app.querySelector('#advNew'); if(advNew) advNew.onclick=async()=>{ if(!state.users){ try{state.users=await API.listUsers();}catch(e){state.users=[];} } const tracers=(state.users||[]).filter(u=>u.role==='tracer'); if(!tracers.length){toast('No tracers loaded');return;} let ti=0; const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.6);z-index:82;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Record an advance
${tracers.map((t,i)=>``).join('')}
${['fuel_advance','cash_advance','reimbursement','penalty'].map((k,i)=>``).join('')}
R
`; app.appendChild(m); let kk='fuel_advance'; m.querySelectorAll('.advT').forEach(b=>b.onclick=()=>{ti=+b.dataset.i;m.querySelectorAll('.advT').forEach(x=>{const on=+x.dataset.i===ti;x.style.background=on?C.navy:'#fff';x.style.color=on?'#fff':C.steel;x.style.borderColor=on?C.navy:C.line;});}); m.querySelectorAll('.advKk').forEach(b=>b.onclick=()=>{kk=b.dataset.k;m.querySelectorAll('.advKk').forEach(x=>{const on=x.dataset.k===kk;x.style.background=on?C.navy:'#fff';x.style.color=on?'#fff':C.steel;x.style.borderColor=on?C.navy:C.line;});}); m.querySelector('#advCancel').onclick=()=>m.remove(); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#advSave').onclick=async()=>{const rands=parseFloat(m.querySelector('#advAmt').value); if(!rands||rands<=0){m.querySelector('#advErr').textContent='Enter a valid amount.';return;} try{ await API.addAdvance({tracerId:tracers[ti].id,kind:kk,amountCents:Math.round(rands*100),note:m.querySelector('#advNote').value.trim()}); m.remove(); invalidateMoney(); toast('Advance recorded'); render(); }catch(e){ m.querySelector('#advErr').textContent=e.message||'Failed'; } };}; app.querySelectorAll('.advOk').forEach(b=>b.onclick=async()=>{try{await API.approveAdvance(b.dataset.id,true);invalidateMoney();toast('Advance approved');render();}catch(e){toast(e.message||'Failed');}}); app.querySelectorAll('.advNo').forEach(b=>b.onclick=async()=>{try{await API.approveAdvance(b.dataset.id,false);invalidateMoney();toast('Declined');render();}catch(e){toast(e.message||'Failed');}}); const poB=app.querySelector('#poBuild'); if(poB) poB.onclick=async()=>{try{const r=await API.buildPayouts(isoCycle());invalidateMoney();toast(r.created+' payout draft'+(r.created===1?'':'s')+' built');render();}catch(e){toast(e.message||'Nothing to build');}}; app.querySelectorAll('.poFuel').forEach(b=>b.onclick=()=>{ moneySheet({title:'Fuel deduction this week',sub:'Default is capped at the set % of what remains after cash. You can go above it.',defAmount:(Number(b.dataset.f)/100).toFixed(2),confirm:'Set'},async d=>{ await API.adjustPayout(b.dataset.id,{deductFuelCents:d.cents}); invalidateMoney(); toast('Adjusted'); render(); });}); app.querySelectorAll('.poConf').forEach(b=>b.onclick=async()=>{try{await API.payoutStatus(b.dataset.id,'confirmed');invalidateMoney();toast('Confirmed');render();}catch(e){toast(e.message||'Failed');}}); app.querySelectorAll('.poPaid').forEach(b=>b.onclick=async()=>{try{await API.payoutStatus(b.dataset.id,'paid');invalidateMoney();toast('Paid — advances settled');render();}catch(e){toast(e.message||'Failed');}}); app.querySelectorAll('.poDel').forEach(b=>b.onclick=async()=>{try{await API.deletePayout(b.dataset.id);invalidateMoney();toast('Draft deleted');render();}catch(e){toast(e.message||'Failed');}}); const reqAdv=app.querySelector('#reqAdv'); if(reqAdv) reqAdv.onclick=()=>{ moneySheet({title:'Request an advance',sub:'Fuel to investigate, or cash before payday. Admin approves; it comes off your Wednesday payout.',chips:['fuel_advance','cash_advance'],askNote:true,notePh:'What it is for',confirm:'Request'},async d=>{ await API.addAdvance({kind:d.kind,amountCents:d.cents,note:d.note}); state._myAdv=null; toast('Requested — waiting for approval'); render(); });}; app.querySelectorAll('.rtab').forEach(b=>b.onclick=()=>{state.rtab=b.dataset.t;render();}); const er=app.querySelector('.exportReport'); if(er) er.onclick=()=>toast('Report exported (PDF) — live in deployment'); const au=app.querySelector('.addUser'); if(au) au.onclick=()=>openUserForm(null); const atpl=app.querySelector('.addTpl'); if(atpl) atpl.onclick=openTemplateUpload; app.querySelectorAll('.delTpl').forEach(b=>b.onclick=async()=>{try{await API.deleteTemplate(b.dataset.id);state.templates=null;toast('Deleted');render();}catch(e){toast('Delete failed');}}); const odoc=app.querySelector('.openDocs'); if(odoc) odoc.onclick=()=>{state.templates=null;state.screen='documents';render();}; const oat=app.querySelector('.openAcctTypes'); if(oat) oat.onclick=()=>{ if(API.token&&!state.accountTypes){API.listAccountTypes().then(a=>{state.accountTypes=a||[];openAcctTypes();}).catch(()=>openAcctTypes());} else openAcctTypes(); }; app.querySelectorAll('.editUser').forEach(b=>b.onclick=()=>{const u=(state.users||[]).find(x=>x.id===b.dataset.id);if(u)openUserForm(u);}); const bd=app.querySelector('.bizDetails'); if(bd) bd.onclick=()=>{state.business=null;state.screen='settings';render();}; const obh=app.querySelector('.openBiz'); if(obh) obh.onclick=()=>{state.profit=null;state.screen='business';render();}; app.querySelectorAll('.tracerCard').forEach(b=>b.onclick=()=>{state.viewTracerId=b.dataset.id;state.tracerDetail=null;state.screen='tracerprofile';render();}); const tb=app.querySelector('.tobiz'); if(tb) tb.onclick=()=>{state.screen='business';render();}; const at=app.querySelector('.addTracer'); if(at) at.onclick=()=>openUserForm({role:'tracer',full_name:'',display_name:'',email:'',phone:'',active:true}); const et=app.querySelector('.editTracer'); if(et) et.onclick=()=>{const t=(state.users||state.tracersReport||[]).find(x=>x.id===et.dataset.id);openUserForm(t||{id:et.dataset.id,role:'tracer'});}; const br=app.querySelector('.bizReport'); if(br) br.onclick=()=>generateBusinessReportPDF(state.profit,state.tracersReport); const trbtn=app.querySelector('.tracerReport'); if(trbtn) trbtn.onclick=()=>generateTracerReportPDF(state.tracerDetail); const tstmt=app.querySelector('.tracerStmt'); if(tstmt) tstmt.onclick=()=>generateTracerStatementPDF(state.tracerDetail); const mystmt=app.querySelector('.myStmt'); if(mystmt) mystmt.onclick=async()=>{ if(API.token && API.userId){ try{ const d=await API.getTracerReport(API.userId); generateTracerStatementPDF(d); }catch(e){ toast('Could not load statement'); } } else toast('Statement needs a live login'); }; const bs=app.querySelector('.bizSave'); if(bs) bs.onclick=async()=>{ const patch={}; app.querySelectorAll('.bizf').forEach(i=>patch[i.dataset.k]=i.value.trim()); try{ await API.updateBusiness(patch); state.business=null; toast('Business details saved'); state.screen='board'; render(); } catch(e){ toast('Save failed'); } }; app.querySelectorAll('.dcase').forEach(b=>b.onclick=()=>{state.caseId=b.dataset.id;state.screen='case';render();}); app.querySelectorAll('.capPay').forEach(b=>b.onclick=async()=>{ if(API.token){ try{ await API.setInvoiceStatus(b.dataset.id,'paid'); const li=(state.liveInv||[]).find(i=>String(i.id)===b.dataset.id); if(li){li.status='paid';} toast('Payment captured'); render(); }catch(e){ toast('Capture failed: '+(e.message||'')); } } else { const inv=INVOICES.find(i=>i.id===b.dataset.id); if(inv){inv.status='paid';inv.paidOn='now';toast('Payment captured · '+inv.id);render();} } }); const cs=app.querySelector('.chatsend'); if(cs){ const send=()=>{const inp=document.getElementById('chatinput');if(!inp.value.trim())return;const txt=inp.value.trim();const m={w:ROLES[state.role].who,r:state.role,t:'now',x:txt};if(state.screen==='casechat'){const cc=CASES.find(x=>x.id===state.caseId);cc.chat.push(m);if(API.token){API.postChat(cc.id,txt).catch(()=>{});}}else{TEAM.push(m);}render();setTimeout(()=>{const sc=document.getElementById('chatscroll');if(sc)sc.scrollTop=sc.scrollHeight;},30);};cs.onclick=send;const inp=document.getElementById('chatinput');if(inp)inp.onkeydown=e=>{if(e.key==='Enter')send();};const sc=document.getElementById('chatscroll');if(sc)sc.scrollTop=sc.scrollHeight;} } function openUmveloFee(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Confirm the fee

Confirm the fee for this recovery per your statement. Heavenly Guards is notified and raises the invoice against it.

FEE (EXCL. VAT)
STATEMENT REFERENCE (optional)
`; app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#ufCancel').onclick=()=>m.remove(); m.querySelector('#ufSend').onclick=async()=>{ const fee=parseFloat((m.querySelector('#ufFee').value||'').replace(/[^\d.]/g,'')); if(!fee||fee<=0){toast('Enter the fee amount');return;} const ref=(m.querySelector('#ufRef').value||'').trim(); const feeCents=Math.round(fee*100); const who=state.userName||(ROLES.umvelo&&ROLES.umvelo.who)||'Umvelo'; const msg='INVOICE REQUESTED — fee R'+(feeCents/100).toFixed(2)+' excl VAT'+(ref?' · statement '+ref:''); c.invoiceRequest={feeCents,ref,by:who}; c.thread.push({r:'umvelo',w:who,t:'now',k:'update',x:msg}); m.remove(); toast('Fee confirmed — Heavenly Guards notified'); render(); if(API.token){try{await API.instructionUpdate(c.id,msg);}catch(e){toast('Saved locally — sync failed');}} }; } function openInvoice(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Create invoice

Umvelo sends a statement confirming the fee for this recovered case. Enter it below to raise the invoice. Company keeps 30%; tracer gets 70% less advances.

${icon('board',14,C.blue)}
From Umvelo's statement: the fee they've confirmed for this recovery. Enter it exactly as stated. You can edit later if a corrected statement arrives.
UMVELO STATEMENT / REFERENCE (optional)
FEE CONFIRMED BY UMVELO (excl. VAT)
`; app.appendChild(m); const feeI=m.querySelector('#invFee'), vatI=m.querySelector('#invVat'), brk=m.querySelector('#invBreak'); const advCents=0; const recalc=()=>{ const fee=parseFloat(feeI.value)||0; if(fee<=0){brk.style.display='none';return;} const feeC=Math.round(fee*100); const vat=vatI.checked?Math.round(feeC*0.15):0; const company=Math.round(feeC*0.30); const tracer=feeC-company-advCents; const R=x=>'R'+(x/100).toLocaleString('en-ZA',{minimumFractionDigits:2}); brk.style.display='block'; brk.innerHTML=`
Fee (ex-VAT)${R(feeC)}
${vat?`
VAT 15%${R(vat)}
`:''}
Umvelo pays${R(feeC+vat)}
Company (30%)${R(company)}
Tracer net${R(tracer)}
`; }; feeI.oninput=recalc; vatI.onchange=recalc; if(feeI.value) recalc(); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#invCancel').onclick=()=>m.remove(); m.querySelector('#invSave').onclick=async()=>{ const fee=parseFloat(feeI.value); if(!fee||fee<=0){toast('Enter the fee');return;} const feeCents=Math.round(fee*100), vatApplies=vatI.checked; if(API.token){ try{ const inv=await API.createInvoice(c.id,feeCents,vatApplies); c.stage='invoiced'; state.liveInv=null; c._invoice=inv||{fee_cents:feeCents,vat_applies:vatApplies,vat_cents:vatApplies?Math.round(feeCents*0.15):0,umvelo_cents:feeCents+(vatApplies?Math.round(feeCents*0.15):0)}; toast('Invoice raised'); m.remove(); if(!state.business){API.getBusiness().then(bz=>{state.business=bz;}).catch(()=>{});} render(); return; } catch(e){ toast('Failed: '+(e.message||'')); return; } } const company=Math.round(feeCents*0.30), tracer=feeCents-company; c.thread.push({r:state.role,w:ROLES[state.role].who,t:'now',k:'update',x:`Invoice raised. Company R${(company/100).toFixed(2)}, tracer net R${(tracer/100).toFixed(2)}.`}); c.stage='invoiced'; toast('Invoice raised'); m.remove(); render(); }; } function openSettle(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; const arrears=c.arrearsCents?('R'+(c.arrearsCents/100).toLocaleString('en-ZA',{minimumFractionDigits:2})):''; m.innerHTML=`
Debtor settles in full

The debtor is paying in full instead of surrendering the vehicle. Capture the payment and proof — the case closes as settled, no uplift.

AMOUNT PAID
${arrears?`
Full arrears: ${arrears}
`:''}
METHOD
${['EFT','Cash','Card'].map((mth,i)=>``).join('')}
PAYMENT REFERENCE
`; app.appendChild(m); let method='EFT', proof=false; m.querySelectorAll('.setMethod').forEach(b=>b.onclick=()=>{method=b.dataset.m;m.querySelectorAll('.setMethod').forEach(x=>{const on=x.dataset.m===method;x.style.background=on?C.navy:'#fff';x.style.color=on?'#fff':C.steel;x.style.borderColor=on?C.navy:C.line;});}); m.querySelector('#setProof').onclick=()=>{const cc=CASES.find(x=>x.id===state.caseId);snapPhoto(cc&&cc.id,'Settlement_proof',ok=>{if(ok){proof=true;const b=m.querySelector('#setProof');b.style.background=C.greenSoft;b.style.borderStyle='solid';b.innerHTML=`${icon('check',15,C.green)} Proof of payment captured`;}});}; m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#setCancel').onclick=()=>m.remove(); m.querySelector('#setSave').onclick=async ()=>{ const amt=parseFloat(m.querySelector('#setAmt').value); if(!amt){toast('Enter the amount');return;} const ref=m.querySelector('#setRef').value.trim(); const cents=Math.round(amt*100); const who=ROLES[state.role]?ROLES[state.role].who:'Tracer'; c.settlement={amountCents:cents,reference:ref,method,status:proof?'paid':'agreed'}; c.stage='recovered'; c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Debtor settled in full — R${(cents/100).toLocaleString('en-ZA',{minimumFractionDigits:2})} via ${method}${ref?' · ref '+ref:''}.${proof?' Proof captured.':' Awaiting proof.'}`}); c.chat.push({w:'System',r:'admin',t:'now',x:`💰 ${who}: debtor settled in full (R${(cents/100).toLocaleString('en-ZA',{minimumFractionDigits:2})}). Case closes as settled — no uplift.`}); m.remove(); toast('Settlement recorded'); render(); if(API.token){try{await API.settle(c.id,cents,ref,method);}catch(e){toast('Saved locally — sync failed');}} }; } function openVisit(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Visit outcome

You attended the site. Record what happened — this is logged as proof of work even when nothing was recovered.

${icon('check',14,C.green)}
GPS + timestamp captured with this visit.
${VISIT_OUTCOMES.map(o=>``).join('')}
`; app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#voCancel').onclick=()=>m.remove(); m.querySelectorAll('.voPick').forEach(b=>b.onclick=async ()=>{ const o=visitOutcome(b.dataset.k); m.remove(); if(o.handover){ hw=null; state.screen='handover'; render(); return; } const who=ROLES[state.role]?ROLES[state.role].who:'Tracer'; // MANDATORY: on-site photo as proof of work toast('Take the on-site photo — required as proof'); snapPhoto(c.id,'Visit_'+o.k,ok=>{ if(!ok){ toast('Visit not logged — the on-site photo is required'); return; } // real GPS stamp grabGPS(gps=>{ const gpsTxt=gps||'GPS unavailable'; c.visits=c.visits||[]; c.visits.push({outcome:o.k,label:o.label,by:who,t:'now',gps:gpsTxt}); c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Site visit — ${o.label}. Photo + GPS (${gpsTxt}) captured as proof of work. Vehicle not recovered.`}); c.chat.push({w:'System',r:'admin',t:'now',x:`📍 ${who} attended ${c.debtor}: ${o.label}. Photo + GPS logged as proof of work.`}); if(o.k==='wrongaddr'){toast('Logged with photo + GPS · consider flagging the address wrong');} else if(o.k==='moved'){toast('Logged with photo + GPS · consider "Moved" on the address');} else toast('Visit logged · photo + GPS proof captured'); render(); if(API.token){API.logVisit(c.id,o.k,o.label,gpsTxt).catch(()=>toast('Saved locally — sync failed'));} }); }); }); } function openSOS(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(120,20,15,.75);z-index:90;display:flex;align-items:center;justify-content:center;padding:24px;'; m.innerHTML=`
${icon('siren',30,C.red)}
SEND SOS?

The office and all tracers will be alerted immediately with your name, live GPS location${c?' and this case':''}. Use if you are in danger or need urgent backup.

`; app.appendChild(m); m.querySelector('#sosNo').onclick=()=>m.remove(); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#sosGo').onclick=()=>{ m.querySelector('#sosGo').textContent='Sending — locking GPS…'; grabGPS(gps=>{ const who=state.userName||(ROLES[state.role]&&ROLES[state.role].who)||'Tracer'; const when=new Date().toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'}); const msg='🚨🚨 SOS — '+who+' NEEDS HELP NOW · '+when+(gps?' · GPS '+gps+' · https://maps.google.com/?q='+gps.replace(/\s/g,''):' · GPS unavailable')+(c?' · on case '+c.debtor+(c.verify&&c.verify.realAddr?' at '+c.verify.realAddr:''):'')+' — CALL AND RESPOND IMMEDIATELY'; TEAM.push({w:who,r:state.role,t:'now',x:msg}); if(c){ c.thread.push({r:state.role,w:who,t:'now',k:'update',x:msg}); } if(API.token){ API.postTeamChat('internal',msg).catch(()=>{}); if(c){ API.instructionUpdate(c.id,msg).catch(()=>{}); } } m.remove(); toast('🚨 SOS sent — office and team alerted'); render(); }); }; } function openAcctTypes(){ const list=state.accountTypes||[]; const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; const row=a=>{const p=PRIO[a.priority]||PRIO.standard;return `
${a.code} ${a.title} ${p.l}
${a.description?`
${a.description}
`:''}
`;}; m.innerHTML=`
Account types & priorities

The whole team sees these on every case — the chip next to the debtor's name tells the tracer how to treat the account.

${list.map(row).join('')||`
None yet — add your first below.
`}
ADD / UPDATE A TYPE
`; app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#atClose').onclick=()=>m.remove(); m.querySelector('#atSave').onclick=async()=>{ const code=m.querySelector('#atCode').value.trim().toUpperCase(), title=m.querySelector('#atTitle').value.trim(); const priority=m.querySelector('#atPrio').value, description=m.querySelector('#atDesc').value.trim(); if(!code||!title){toast('Code and name are required');return;} try{ await API.saveAccountType({code,title,priority,description}); state.accountTypes=await API.listAccountTypes(); m.remove(); toast(code+' saved'); openAcctTypes(); }catch(e){ toast('Save failed: '+(e.message||'')); } }; m.querySelectorAll('.atDel').forEach(b=>b.onclick=async()=>{ if(!window.confirm('Remove this account type?'))return; try{ await API.deleteAccountType(b.dataset.id); state.accountTypes=await API.listAccountTypes(); m.remove(); openAcctTypes(); }catch(e){ toast('Delete failed'); } }); } function openLeadNote(kind,i){ const c=CASES.find(x=>x.id===state.caseId); const arr=kind==='relative'?c.intel.relatives:c.intel.employers; const it=arr[i]; if(!it)return; const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Lead: ${it.name}

What did you find out? A number for this lead makes them one tap away.

`; app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#lnCancel').onclick=()=>m.remove(); m.querySelector('#lnSave').onclick=async()=>{ const phone=m.querySelector('#lnPhone').value.trim(), note=m.querySelector('#lnNote').value.trim(); it.phone=phone||it.phone; it.note=note||it.note; m.remove(); toast('Lead updated'); render(); if(API.token&&it._id){ API.setLead(kind,it._id,{note,phone}).catch(()=>toast('Sync failed')); } }; } function openSpotted(){ const c=CASES.find(x=>x.id===state.caseId); toast('Take the photo of the vehicle — required'); snapPhoto(c.id,'Vehicle_spotted',ok=>{ if(!ok){ toast('Sighting not logged — photo required'); return; } grabGPS(gps=>{ const who=state.userName||(ROLES[state.role]&&ROLES[state.role].who)||'Tracer'; const msg='🚗 VEHICLE SPOTTED by '+who+(gps?' · GPS '+gps+' · https://maps.google.com/?q='+gps.replace(/\s/g,''):' · GPS unavailable')+' · photo attached. '+c.asset+' '+(c.reg||''); c.thread.push({r:state.role,w:who,t:'now',k:'update',x:msg}); c.chat.push({w:'System',r:'admin',t:'now',x:msg}); toast('🚗 Sighting logged — office alerted'); render(); if(API.token){ API.instructionUpdate(c.id,msg).catch(()=>{}); } }); }); } function openAddIntel(){ const c=CASES.find(x=>x.id===state.caseId); const kinds=[['number','Contact number','e.g. 073 555 1234','Where from? e.g. neighbour gave it'],['address','Address','e.g. 12 Main Rd, Site B, Khayelitsha',''],['relative','Possible relative','e.g. Nomsa Dlamini','Link — e.g. sister, uncle'],['employer','Employer','e.g. Shoprite Khayelitsha','Role — e.g. cashier, day shift']]; let kind='number'; const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; const draw=()=>{ const k=kinds.find(x=>x[0]===kind); m.innerHTML=`
Add new intelligence

Picked up something on site or from the office? Add it here — it saves to the case for everyone and is logged with your name.

${kinds.map(([kk,l])=>``).join('')}
${k[1].toUpperCase()}
${k[3]?``:'
'}
`; m.querySelectorAll('.aiKind').forEach(b=>b.onclick=()=>{kind=b.dataset.k;draw();}); m.querySelector('#aiCancel').onclick=()=>m.remove(); m.querySelector('#aiSave').onclick=async()=>{ const val=(m.querySelector('#aiVal').value||'').trim(); const ex=m.querySelector('#aiExtra')?(m.querySelector('#aiExtra').value||'').trim():''; if(!val){toast('Enter the '+kind);return;} c.intel=c.intel||{numbers:[],addresses:[],relatives:[],employers:[],alerts:[]}; if(kind==='number') c.intel.numbers.push({n:val,type:ex||'Added in field',score:null,status:''}); else if(kind==='address') c.intel.addresses.push({a:val,score:null}); else if(kind==='relative') c.intel.relatives.push({name:val,link:ex}); else c.intel.employers.push({name:val,role:ex}); const who=state.userName||(ROLES[state.role]&&ROLES[state.role].who)||'Team'; c.thread.push({r:state.role,w:who,t:'now',k:'update',x:'New intelligence added — '+kind+': '+val+(ex?' ('+ex+')':'')+'.'}); m.remove(); toast('Intel added to the case'); render(); if(API.token){try{const rr=await API.addIntel(c.id,kind,val,ex);if(rr&&rr.matches&&rr.matches.length){toast('⚠ LINK: this number is also on '+rr.matches.map(m=>m.debtor_name).join(', ')+' — cross-case lead!');}}catch(e){toast('Saved locally — sync failed');}} }; }; draw(); app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; } function openFieldUpdate(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Update to office

Tell the office what's happening on site. A note and at least one photo are required.

WHAT'S HAPPENING? (required)
0 photos attached
`; app.appendChild(m); let photos=0; const sendBtn=m.querySelector('#fuSend'), note=m.querySelector('#fuNote'); const refresh=()=>{ const ready=photos>0&¬e.value.trim().length>4; sendBtn.disabled=!ready; sendBtn.style.background=ready?C.green:C.line; sendBtn.style.color=ready?'#fff':C.steel; }; note.oninput=refresh; m.querySelector('#fuPhoto').onclick=()=>snapPhoto(c.id,'FieldUpdate',ok=>{if(ok){photos++;m.querySelector('#fuCount').textContent=photos+' photo'+(photos>1?'s':'')+' attached';refresh();}}); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#fuCancel').onclick=()=>m.remove(); sendBtn.onclick=()=>{ if(sendBtn.disabled) return; const txt=note.value.trim(); grabGPS(gps=>{ const who=ROLES[state.role]?ROLES[state.role].who:'Tracer'; c.thread.push({r:state.role,w:who,t:'now',k:'update',x:txt+' — '+photos+' photo'+(photos>1?'s':'')+' attached'+(gps?', GPS '+gps:'')+'.'}); c.chat.push({w:'System',r:'admin',t:'now',x:'📸 '+who+' sent a field update on '+c.debtor+' with '+photos+' photo'+(photos>1?'s':'')+'.'}); if(API.token){API.instructionUpdate(c.id,txt+' ['+photos+' photos'+(gps?', GPS '+gps:'')+']').catch(()=>{});} m.remove(); toast('Update + photos sent to office'); state.screen='case'; render(); }); }; } function openDispo(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.id='dispoSheet'; m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; const tiles=DISPOSITIONS.map(d=>``).join(''); m.innerHTML=`
Log the outcome

What happened on this call or visit? The case updates and reminders are set automatically.

${tiles}
`; app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelectorAll('.dpick').forEach(b=>b.onclick=()=>{ m.querySelectorAll('.dpick').forEach(x=>{x.style.borderColor=C.line;x.style.background='#fff';}); const d=dispo(b.dataset.d); b.style.borderColor=d.color; b.style.background=d.color+'12'; renderFollow(m,c,d); }); } function renderFollow(m,c,d){ const box=m.querySelector('#dFollow'); box.style.display='block'; let fields=''; if(d.needs==='time') fields=`
CALL BACK IN
${['In 2 hours','In 4 hours','Tomorrow 9am','Tomorrow 3pm'].map((t,i)=>``).join('')}
`; else if(d.needs==='date') fields=`
PROMISED DATE
`; else if(d.needs==='datePlace') fields=`
DATE & TIME
PLACE
`; else if(d.needs==='callback') fields=`
TRY AGAIN
${['In 2 hours','Later today','Tomorrow'].map(t=>``).join('')}
`; else if(d.needs==='note') fields=`
NOTE
`; box.innerHTML=fields+``; let picked=null; box.querySelectorAll('.qt').forEach(b=>b.onclick=()=>{box.querySelectorAll('.qt').forEach(x=>{x.style.borderColor=C.line;x.style.background='#fff';});b.style.borderColor=C.navy;b.style.background=C.bone;picked=b.dataset.w;}); box.querySelector('#dSave').onclick=()=>{ const whenIn=box.querySelector('#dWhen'); const placeIn=box.querySelector('#dPlace'); const noteIn=box.querySelector('#dNote'); const when = picked || (whenIn?whenIn.value.trim():'') || 'follow up'; const place = placeIn?placeIn.value.trim():''; const note = noteIn?noteIn.value.trim():''; // set next action on the case c.nextAction = { dispo:d.k, label:d.label, when:when, place:place, overdue:false }; const who = ROLES[state.role]?ROLES[state.role].who:'User'; const roleLbl = ROLES[state.role]?ROLES[state.role].label:''; const msg=`Outcome logged by ${roleLbl}: ${d.label}. ${when}${place?' at '+place:''}${note?' — '+note:''}`; c.thread.push({r:state.role,w:who,t:'now',k:'update',x:msg}); // commitments (PTP/PTS/arrangement) alert office; broken-flag handled by dashboard logic if(d.good){ c.chat.push({w:'System',r:'admin',t:'now',x:`📅 ${d.label} logged by ${who} (${roleLbl}): ${when}${place?' · '+place:''}. Reminder set; visible on Admin dashboard.`}); } const sheet=document.getElementById('dispoSheet'); if(sheet) sheet.remove(); toast(`${d.label} logged · reminder set`); render(); if(API.token){ API.instructionUpdate(c.id,msg).catch(()=>toast('Saved locally — sync failed')); } }; } function completeHandover(){ grabGPS(g=>_completeHandover(g||'GPS unavailable')); } function _completeHandover(liveGps){ const c=CASES.find(x=>x.id===state.caseId); const who=ROLES[state.role]?ROLES[state.role].who:'Tracer'; const roleLbl=ROLES[state.role]?ROLES[state.role].label:''; const refused=hw.refused; c.files=c.files||[]; // capture each included document — signed, or presented-and-declined HANDOVER_DOCS.forEach(d=>{ if(!hw.include[d.k]) return; if(d.needsTow && !hw.tow) return; const tag=refused&&d.signs.includes('client')?'_DECLINED_':'_SIGNED_'; const nm=d.label.replace(/[^A-Za-z0-9]/g,'')+tag+c.debtor.replace(/\s/g,'')+'.pdf'; c.files.push({n:nm,t:'other',by:who+' ('+roleLbl+')',d:'now',signed:!refused||!d.signs.includes('client'),declined:refused&&d.signs.includes('client')}); }); // evidence photos from sections + dash + client photo c.files.push({n:'Handover_photo_'+c.debtor.replace(/\s/g,'')+'.jpg',t:'photo',by:who,d:'now'}); if(hw.kmPhoto) c.files.push({n:'Odometer_'+c.debtor.replace(/\s/g,'')+'.jpg',t:'photo',by:who,d:'now'}); const secPhotos=hw.sections?Object.values(hw.sections).reduce((s,x)=>s+(x.photos||0),0):0; if(secPhotos) c.files.push({n:'Inspection_'+secPhotos+'photos_'+c.debtor.replace(/\s/g,'')+'.zip',t:'photo',by:who,d:'now'}); // build the summary receipt const badSecs=hw.sections?INSPECT_SECTIONS.filter(s=>hw.sections[s.k].cond==='Bad').map(s=>s.label):[]; const insp=hw.sections?INSPECT_SECTIONS.map(s=>s.label+': '+hw.sections[s.k].cond).join(', '):''; c.handoverSummary={ when:'now', by:who, gps:liveGps, km:hw.km||'—', fuel:hw.fuel||'—', belongings:hw.belongings, tow:hw.tow, witness:hw.witness?hw.witnessName||'Witness':null, refused:refused, refusedNote:hw.refusedNote||'', photos:secPhotos+(hw.kmPhoto?1:0)+1, expenses:hw.expenses.slice(), docs:HANDOVER_DOCS.filter(d=>hw.include[d.k]&&!(d.needsTow&&!hw.tow)).map(d=>d.label), inspect:insp, badSecs:badSecs }; c.files.push({n:'Handover_Summary_'+c.debtor.replace(/\s/g,'')+'.pdf',t:'other',by:who,d:'now',summary:true}); // expenses → case hw.expenses.forEach(e=>{ c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Handover expense: ${e.type} R${e.amount}${e.receipt?' (receipt attached — refundable)':' (no receipt)'}.`}); }); if(hw.tow) c.chat.push({w:'System',r:'admin',t:'now',x:`🚚 Towing requested from Umvelo for ${c.debtor} — vehicle not driveable.`}); // main activity if(refused){ c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Handover — CLIENT REFUSED TO SIGN. Documents presented and declined. Tracer signed, GPS + ${c.handoverSummary.photos} photos captured. ${hw.refusedNote?'Note: '+hw.refusedNote:''}`}); c.chat.push({w:'System',r:'admin',t:'now',x:`⚠ ${c.debtor}: client refused to sign. Presented & declined recorded with photo + GPS. Office to review.`}); } else { const sc=HANDOVER_DOCS.filter(d=>hw.include[d.k]&&d.signs.length&&!(d.needsTow&&!hw.tow)).length; c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Handover completed. ${sc} documents signed${hw.witness?' + witness ('+(hw.witnessName||'witness')+')':''}, KM ${hw.km||'—'}, fuel ${hw.fuel||'—'}, GPS + ${c.handoverSummary.photos} photos captured. Vehicle ${hw.tow?'awaiting tow':'driven to trade centre'}.`}); c.chat.push({w:'System',r:'admin',t:'now',x:`✅ Handover complete on ${c.debtor}. Signed docs on case files.${hw.expenses.length?' '+hw.expenses.length+' expense(s) to refund.':''}`}); } c._signoff={include:{...hw.include},signed:{...hw.signed},refused:hw.refused,refusedNote:hw.refusedNote,belongings:hw.belongings,tow:hw.tow,witness:hw.witness,witnessName:hw.witnessName}; hw=null; state.screen='case'; // persist to live API if(API.token){ const payload={km:c.handoverSummary.km,fuel:c.handoverSummary.fuel,towed:!!c.handoverSummary.tow, witnessName:c.handoverSummary.witness,refused:!!c.handoverSummary.refused,refusedNote:c.handoverSummary.refusedNote, gps:c.handoverSummary.gps,sections:c.handoverSummary.inspect,expenses:[],docs:c.handoverSummary.docs}; API.handover(c.id,payload).catch(()=>{}); // expenses go through the real expense pipeline (receipt-gated, approvable) c.handoverSummary.expenses.forEach(e=>{ API.addExpense(c.id,{kind:e.type,amountCents:Math.round((e.amount||0)*100),note:(e.note||'')+' (logged at handover)',receiptFileId:e.receiptFileId||null}).catch(()=>{}); }); state._exp=state._exp||{}; state._exp[c.id]=undefined; } openReceipt(c); // show the give-copy receipt render(); } function openReceipt(c){ const s=c.handoverSummary; if(!s) return; const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.6);z-index:80;display:flex;align-items:center;justify-content:center;padding:20px;'; m.innerHTML=`
${icon(s.refused?'alert':'check',30,'#fff')}
${s.refused?'Declined handover recorded':'Handover complete'}
${c.debtor} · ${c.reg}
SUMMARY RECEIPT
${[['Time & GPS','now · '+s.gps],['Odometer',s.km],['Fuel',s.fuel],['Belongings removed',s.belongings?'Confirmed':'Not confirmed'],['Vehicle',s.tow?'Awaiting tow':'Driven to trade centre'],['Witness',s.witness||'None'],['Photos captured',s.photos+''],['Condition flags',(s.badSecs&&s.badSecs.length)?s.badSecs.join(', ')+' (Bad)':'None — all Fair'],['Documents',s.docs.join(', ')]].map(([k,v])=>`
${k}${v}
`).join('')} ${s.expenses.length?`
Expenses${s.expenses.map(e=>e.type+' R'+e.amount).join(', ')}
`:''} ${s.refused&&s.refusedNote?`
Refusal note: ${s.refusedNote}
`:''}
GIVE THE CLIENT A COPY
`; app.appendChild(m); m.querySelector('#rcGenDocs').onclick=()=>{ if(!state.business&&API.token){API.getBusiness().then(bz=>{state.business=bz;generateSignoffDocs(c,c._signoff||{});}).catch(()=>generateSignoffDocs(c,c._signoff||{}));}else generateSignoffDocs(c,c._signoff||{}); }; m.querySelector('#rcClose').onclick=()=>m.remove(); m.querySelector('.rcSms').onclick=()=>{m.remove();toast('Signed pack sent to client by SMS');}; m.querySelector('.rcEmail').onclick=()=>{m.remove();toast('Signed pack emailed to client');}; m.querySelector('.rcQr').onclick=()=>{m.remove();toast('QR code shown for client to scan');}; } let _pdfjsP=null; function loadPdfJs(){ if(window.pdfjsLib) return Promise.resolve(); if(_pdfjsP) return _pdfjsP; _pdfjsP=new Promise((res,rej)=>{ const s=document.createElement('script'); s.src='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; s.onload=()=>{ try{ window.pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; res(); }catch(e){ rej(e); } }; s.onerror=()=>rej(new Error('PDF viewer library failed to load')); document.head.appendChild(s); }); return _pdfjsP; } function openFile(f){ if(!f) return; const m=fileMeta(f.t); const isPdf=/\.pdf$/i.test(f.n||''); const isImg=/\.(jpe?g|png|gif|webp)$/i.test(f.n||''); const sheet=document.createElement('div'); sheet.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.7);z-index:75;display:flex;flex-direction:column;'; sheet.innerHTML=`
${f.n}
${m.label}${f.by?' · '+f.by:''}${f.d?' · '+f.d:''}
${f.key?``:''}
Loading ${isPdf?'document':'file'}…
`; app.appendChild(sheet); const close=()=>{ if(sheet._url) URL.revokeObjectURL(sheet._url); sheet.remove(); }; sheet.querySelector('#fClose').onclick=close; const body=sheet.querySelector('#fBody'); if(!f.key){ body.innerHTML=`
${icon('board',34,C.line)}
${f.n}
This document is recorded on the case but has no stored copy to display.
`; return; } // fetch the real file through the authed endpoint, then render it (async()=>{ try{ const r=await fetch(API.base+'/files/download/'+encodeURI(f.key),{headers:{Authorization:'Bearer '+API.token}}); if(!r.ok) throw new Error('HTTP '+r.status); const blob=await r.blob(); const url=URL.createObjectURL(blob); sheet._url=url; if(isPdf){ // iPhones render blob-PDF iframes as a blank box, so we draw the pages // ourselves: every page becomes a scrollable image. Works on iOS + Android. body.style.padding='0'; body.style.display='block'; body.innerHTML=`
`; try{ await loadPdfJs(); const buf=await blob.arrayBuffer(); const doc=await window.pdfjsLib.getDocument({data:buf}).promise; const cont=body.querySelector('#pdfPages'); const meta=body.querySelector('#pdfMeta'); const W=Math.min((body.clientWidth||360)-16, 900); for(let p=1;p<=doc.numPages;p++){ const page=await doc.getPage(p); const v0=page.getViewport({scale:1}); const scale=(W/v0.width)*Math.min(window.devicePixelRatio||1,2); const vp=page.getViewport({scale}); const cv=document.createElement('canvas'); cv.width=vp.width; cv.height=vp.height; cv.style.cssText='width:100%;max-width:'+W+'px;display:block;margin:0 auto 10px;background:#fff;border-radius:8px;box-shadow:0 2px 10px rgba(12,25,38,.14);'; cont.appendChild(cv); await page.render({canvasContext:cv.getContext('2d'),viewport:vp}).promise; meta.textContent='Page '+p+' of '+doc.numPages; } meta.textContent=doc.numPages+' page'+(doc.numPages===1?'':'s')+' — pinch to zoom, Open for full app'; }catch(pe){ body.style.display='flex'; body.innerHTML=`
${icon('board',30,C.steelLite)}
${f.n}
In-app preview unavailable (${pe.message||'renderer error'}). Tap Open above to view it in your PDF app.
`; } } else if(isImg){ body.innerHTML=``; } else { body.innerHTML=`
${icon('board',34,C.steelLite)}
${f.n}
Tap Open to view or download this file.
`; } const ot=sheet.querySelector('#fOpenTab'); if(ot) ot.onclick=()=>{ const a=document.createElement('a'); a.href=url; a.target='_blank'; a.rel='noopener'; a.download=f.n||'document'; a.click(); }; }catch(e){ body.innerHTML=`
${icon('alert',30,C.red)}
Could not open this document
${e.message||'The file may no longer be on the server.'}
`; } })(); } function openAttach(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; const types=Object.entries(FILE_TYPES).map(([k,v])=>``).join(''); m.innerHTML=`
Attach a document

Add a file to this case. It's kept permanently and everyone on the case can view it.

DOCUMENT TYPE
${types}
${icon('board',24,C.steel)}
Tap a type above, then choose the file.
`; app.appendChild(m); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#atCancel').onclick=()=>m.remove(); m.querySelectorAll('.atype').forEach(b=>b.onclick=()=>{ const t=b.dataset.t; const meta=fileMeta(t); const who=ROLES[state.role]?ROLES[state.role].who:'User'; const roleLbl=ROLES[state.role]?ROLES[state.role].label:''; c.files=c.files||[]; const name=(meta.label.replace(/[^A-Za-z]/g,'')||'Document')+'_'+c.debtor.replace(/\s/g,'')+'.pdf'; c.files.push({n:name,t:t,by:who+' ('+roleLbl+')',d:'now'}); c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Attached document: ${name} (${meta.label}).`}); m.remove(); toast('Document attached to case'); render(); }); } function openCaptureAddr(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Capture the real address

You're standing here — your GPS is the truth. Enter the address you're at; it's saved with your coordinates and becomes the confirmed address on the case.

${icon('pin',15,C.gold)}
Locking GPS…
ACTUAL ADDRESS
`; app.appendChild(m); let liveGps=null; grabGPS(g=>{ liveGps=g; const box=m.querySelector('#gpsBox'); if(!box)return; box.style.background=g?C.greenSoft:C.redSoft; box.innerHTML=icon('pin',15,g?C.green:C.red)+'
'+(g?('GPS locked · '+g+' · now'):'GPS unavailable — enable location and try again')+'
'; }); m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#raCancel').onclick=()=>m.remove(); m.querySelector('#raSave').onclick=async ()=>{ const val=m.querySelector('#realAddr').value.trim(); if(!val){toast('Enter the address');return;} if(!liveGps){toast('Waiting for GPS lock — needed to confirm the location');return;} c.verify=c.verify||{num:{},addr:{}}; c.verify.realAddr=val; c.verify.realGps=liveGps; const who=ROLES[state.role]?ROLES[state.role].who:'Field'; c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Real address captured on site (GPS ${liveGps}): ${val}.`}); c.chat.push({w:'System',r:'admin',t:'now',x:`📍 ${who} captured the confirmed address for ${c.debtor} on site with GPS. This is now the ground truth.`}); m.remove(); toast('Confirmed address saved'); render(); if(API.token){try{await API.captureAddress(c.id,val,liveGps);}catch(e){toast('Saved locally — sync failed');}} }; } function openInsUpdate(){ const c=CASES.find(x=>x.id===state.caseId); const m=document.createElement('div'); m.id='insSheet'; m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Post an instruction update

The original instruction stays locked. Your update is added on top, stamped with your name and the time, and everyone on the case is alerted.

WHAT'S CHANGED / NEW INFORMATION
`; app.appendChild(m); const ta=m.querySelector('#insText'); const save=m.querySelector('#insSave'); const refresh=()=>{const ok=ta.value.trim();save.style.background=ok?C.navy:C.line;save.style.color=ok?'#fff':C.steel;save.style.cursor=ok?'pointer':'not-allowed';}; ta.oninput=refresh; m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#insCancel').onclick=()=>m.remove(); save.onclick=async ()=>{ const txt=ta.value.trim(); if(!txt) return; const who=ROLES[state.role]?ROLES[state.role].who:'User'; const roleLbl=ROLES[state.role]?ROLES[state.role].label:''; c.instruction.updates=c.instruction.updates||[]; c.instruction.updates.push({t:'now',by:who+' ('+roleLbl+')',x:txt}); c.thread.push({r:state.role,w:who,t:'now',k:'update',x:`Instruction update: ${txt}`}); c.chat.push({w:'System',r:'admin',t:'now',x:`📋 Instruction updated by ${who} (${roleLbl}): "${txt}" — everyone on the case alerted. Original instruction remains locked.`}); m.remove(); toast('Instruction update posted · case alerted'); render(); if(API.token){try{await API.instructionUpdate(c.id,txt);}catch(e){toast('Saved locally — sync failed');}} }; } function openReassign(){ const c=CASES.find(x=>x.id===state.caseId); if(API.token && !state.tracerList){ API.listTracers().then(r=>{state.tracerList=r||[];openReassign();}).catch(()=>{state.tracerList=[];openReassign();}); toast('Loading tracers…'); return; } const others=API.token ? (state.tracerList||[]).filter(t=>t.display_name!==c.tracer).map(t=>({id:t.id,name:t.display_name})) : ROSTER.filter(t=>t!==c.tracer).map(t=>({id:null,name:t})); const m=document.createElement('div'); m.id='reassignSheet'; m.style.cssText='position:absolute;inset:0;background:rgba(12,25,38,.55);z-index:70;display:flex;align-items:flex-end;justify-content:center;'; m.innerHTML=`
Change tracer

Currently ${c.tracer}. A reason is required — the change is logged and Umvelo, the office, and both tracers are alerted.

NEW TRACER
${others.map(t=>``).join('')||`
No other active tracers.
`}
REASON (REQUIRED)
`; app.appendChild(m); let picked=null, pickedId=null; const confirmBtn=m.querySelector('#rsConfirm'); const refresh=()=>{const reason=m.querySelector('#rsReason').value.trim();const ok=picked&&reason;confirmBtn.style.background=ok?C.navy:C.line;confirmBtn.style.color=ok?'#fff':C.steel;confirmBtn.style.cursor=ok?'pointer':'not-allowed';}; m.querySelectorAll('.rsPick').forEach(b=>b.onclick=()=>{picked=b.dataset.t;pickedId=b.dataset.id||null;m.querySelectorAll('.rsPick').forEach(x=>{x.style.borderColor=C.line;x.style.background='#fff';});b.style.borderColor=C.green;b.style.background=C.greenSoft;refresh();}); m.querySelector('#rsReason').oninput=refresh; m.onclick=e=>{if(e.target===m)m.remove();}; m.querySelector('#rsCancel').onclick=()=>m.remove(); confirmBtn.onclick=async()=>{ const reason=m.querySelector('#rsReason').value.trim(); if(!picked||!reason)return; const from=c.tracer, to=picked; if(API.token&&pickedId){ confirmBtn.textContent='Reassigning…'; try{ await API.reassignCase(c.id,pickedId,reason); } catch(e){ toast('Reassign failed: '+(e.message||'')); confirmBtn.textContent='Reassign & alert'; return; } } c.assignLog=c.assignLog||[]; c.assignLog.unshift({from,to,by:ROLES[state.role].who+' ('+ROLES[state.role].label+')',reason,time:'now'}); c.tracer=to; c.assignStatus='confirmed'; c.assignedBy=ROLES[state.role].who; c.thread.push({r:state.role,w:ROLES[state.role].who,t:'now',k:'allocate',x:`Tracer changed: ${from} → ${to}. Reason: ${reason}. ${to} must accept the case.`}); c.chat.push({w:'System',r:'admin',t:'now',x:`⚠ Tracer changed from ${from} to ${to} by ${ROLES[state.role].who}. Reason: ${reason}. ${to} sees it on their board to accept.`}); m.remove(); toast(`Reassigned to ${to} — awaiting their acceptance`); render(); }; } function roleMenu(){ const ex=document.getElementById('rolemenu'); if(ex){ex.remove();return;} const m=document.createElement('div'); m.id='rolemenu'; m.style.cssText=`position:absolute;top:54px;right:16px;background:#fff;border-radius:12px;box-shadow:0 12px 30px rgba(12,25,38,.3);overflow:hidden;z-index:60;min-width:190px;animation:pop .2s both;`; m.innerHTML=`
VIEW AS (demo)
`+ Object.entries(ROLES).map(([k,r])=>``).join(''); app.appendChild(m); m.querySelectorAll('[data-r]').forEach(b=>b.onclick=()=>{state.role=b.dataset.r;m.remove();state.screen='board';render();}); } function toast(msg){ const t=document.createElement('div'); t.style.cssText=`position:absolute;bottom:90px;left:16px;right:16px;background:${C.navy};color:#fff;border-radius:12px;padding:13px 16px;display:flex;align-items:center;gap:10px;z-index:80;box-shadow:0 10px 30px rgba(12,25,38,.35);animation:pop .3s both;max-width:428px;margin:0 auto;`; t.innerHTML=`${icon('check',18,C.dawn)}${msg}`; app.appendChild(t); setTimeout(()=>t.remove(),2600); } // ================= LIVE REFRESH — system-wide updates without manual reload ================= // Polls the server every 20s and re-renders only when something actually changed. // Skips politely while the person is typing or has a sheet open, so nothing is disturbed. let _liveSig='', _liveBusy=false; function _overlayOpen(){ return [...app.children].some(el=>el.style && parseInt(el.style.zIndex||0)>=70); } function _typing(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); } async function liveRefresh(){ if(!API.token || document.hidden || _liveBusy) return; if(_typing() || _overlayOpen() || state.screen==='handover' || state.screen==='signing') return; _liveBusy=true; try{ // 1) case list — board, dashboards, stats (already mapped by the adapter) const list=await API.loadCases(); const sig=(list||[]).map(r=>r.id+':'+r.stage+':'+(r.assignStatus||'')+':'+(r.tracer||'')).join('|'); if(sig!==_liveSig){ _liveSig=sig; const openId=state.caseId; const keepOpen=CASES.find(x=>x.id===openId && !x._partial); CASES=(list||[]).map(li=> (keepOpen && li.id===openId) ? keepOpen : li ); render(); } // 2) the case being viewed — thread, chat, visits, updates if(state.caseId && ['case','field','casechat'].includes(state.screen)){ const full=await API.loadCase(state.caseId); const cur=CASES.find(x=>x.id===state.caseId); const changed=!cur || cur._partial || (full.thread||[]).length!==(cur.thread||[]).length || (full.chat||[]).length!==(cur.chat||[]).length || full.stage!==cur.stage || full.tracer!==cur.tracer || full.assignStatus!==cur.assignStatus; if(changed){ const k=CASES.findIndex(x=>x.id===state.caseId); if(k>-1){CASES[k]=full; render();} } } // 3) notifications — bell badge + centre try{ const nf=await API.getNotifications(); if(nf && (nf.unread!==(state.notifUnread||0) || (nf.items||[]).length!==(state.notifItems||[]).length)){ const prevUnread=state.notifUnread||0; state.notifUnread=nf.unread; state.notifItems=nf.items; if(nf.unread>prevUnread){ const newest=nf.items.find(n=>!n.read); if(newest) toast((newest.kind==='sos'?'🚨 ':'🔔 ')+newest.title); } render(); } }catch(e){} // 3b) email intake pending count if(state.role==='admin'||state.role==='umvelo'){ try{ const ik=await API.listIntake('pending'); if(ik && ik.pendingCount!==(state.intakePending||0)){ const prev=state.intakePending||0; state.intakePending=ik.pendingCount; if(state.screen==='intake') state.intakeItems=ik.items||[]; if(ik.pendingCount>prev) toast('📥 New instruction email received'); render(); } }catch(e){} } // 4) team chat channel being viewed if(state.screen==='teamchannel' && state.teamChan){ const msgs=await API.getTeamChat(state.teamChan); const mapped=(msgs||[]).map(m=>({w:m.author_name,r:m.role,t:new Date(m.created_at).toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'}),x:m.body})); if(mapped.length!==(state.chanMsgs||[]).length){ state.chanMsgs=mapped; render(); const sc=document.getElementById('chanscroll')||document.getElementById('chatscroll'); if(sc)sc.scrollTop=sc.scrollHeight; } } }catch(e){/* offline or transient — try again next tick */} _liveBusy=false; } setInterval(liveRefresh,20000); document.addEventListener('visibilitychange',()=>{ if(!document.hidden) liveRefresh(); }); render();