Prd/components/backend/public/console.html
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer
answers technician calls, records both call legs as separate channels, and a
Ruby backend transcribes them through Whisper and files an AI summary against
the caller.

Squashed to a single commit for sharing. No credentials are included; secrets
live outside the repo in /etc/helpdesk/env on the server or a gitignored
.claude/env.local locally. See .claude/env.local.example for the shape.

Start at README.md, then docs/architecture.md.
2026-07-27 18:50:32 +02:00

819 lines
60 KiB
HTML

<!doctype html>
<!-- The whole operator console in one file: live call card (screen-pop, in-call controls, wrap-up),
call archive, people directory, person detail with the AI dossier + resolutions ledger, call
detail with transcript/summary/recording, click-to-dial, phone-presence badge. Talks to the
operator API under /api/v1 (see server.rb `route`); auth is handled upstream by the mTLS proxy.
grep anchors: renderContext (AI dossier), pollContext, confirmNotes, rebuildContext, dial. -->
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Helpdesk - Dispatch</title>
<!-- Same stack as the vmin.cz management app: MDBootstrap (MDB UI Kit free) + Font Awesome 6.5 + Roboto -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mdb-ui-kit@7.3.2/css/mdb.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
<style>
/* --- overrides copied from the management app's application.scss --- */
body{ padding-top:56px; }
.navbar-brand{ font-weight:600; letter-spacing:.02em; }
.card{ box-shadow:0 2px 8px rgba(0,0,0,.1); border-radius:8px; border:none; }
.card-body{ padding:1.5rem; }
.table{ font-size:.9rem; }
.table thead th{ font-weight:600; text-transform:uppercase; font-size:.75rem; letter-spacing:.5px; border-bottom:2px solid #dee2e6; }
.table code, code.serial{ background:#f5f5f5; padding:.2rem .4rem; border-radius:3px; font-size:.85em; }
/* one style for every small uppercase field/section label, so they stop drifting in size + weight */
.micro-label{ text-transform:uppercase; font-weight:700; font-size:.7rem; letter-spacing:.5px; color:#6c757d; }
/* --- console-specific, kept in the same restrained idiom --- */
.op-tmobile{ background:#e20074!important; color:#fff!important; border-color:#e20074!important; }
.op-vodafone{ background:#e60000!important; color:#fff!important; border-color:#e60000!important; }
.op-unrouted{ background:#6c757d!important; color:#fff!important; }
.call-card{ border-left:4px solid var(--op,#6c757d)!important; }
.stage-min{ min-height:340px; }
.pulse{ animation:pulse 1.2s ease-in-out infinite; }
@keyframes pulse{ 0%,100%{ opacity:1 } 50%{ opacity:.45 } }
.answer-glow{ animation:glow 1.5s ease-in-out infinite; }
@keyframes glow{ 0%,100%{ box-shadow:0 0 0 0 rgba(18,102,241,.4) } 50%{ box-shadow:0 0 0 .5rem rgba(18,102,241,0) } }
.history-list{ list-style:none; margin:0; padding:0; }
.history-list li{ display:flex; gap:.6rem; padding:.35rem 0; border-top:1px solid #eee; font-size:.875rem; }
.history-list li:first-child{ border-top:0; }
.num-lg{ font-family:var(--bs-font-monospace); font-size:1.4rem; }
#res .btn{ transition:all .12s ease; }
.outcome-sel{ box-shadow:0 .25rem .6rem rgba(0,0,0,.28)!important; transform:translateY(-2px) scale(1.03); z-index:1; }
/* workspace toolbar + people */
#wsTabs .btn, #opFilter .btn{ text-transform:none; font-weight:500; position:relative; }
/* raise the hovered/active segment so its own border owns the divider - stops the split line "jumping" on hover */
#wsTabs .btn:hover, #wsTabs .btn:focus-visible, #wsTabs .btn.btn-primary,
#opFilter .btn:hover, #opFilter .btn:focus-visible, #opFilter .btn.btn-primary,
#opFilter .btn.op-tmobile, #opFilter .btn.op-vodafone{ z-index:2; }
.avatar{ width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center;
color:#fff; font-weight:600; flex-shrink:0; }
.person-row{ cursor:pointer; }
.ctx-box textarea{ min-height:96px; }
</style>
</head>
<body>
<!-- navbar: mirrors the management app's fixed-top bg-primary navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
<div class="container-fluid">
<a class="navbar-brand text-white me-auto" href="/"><i class="fas fa-headset me-2"></i>Helpdesk - Dispatch</a>
<ul class="navbar-nav ms-auto align-items-lg-center">
<!-- certificate expiry: hidden entirely while everything is in date, so it is silent until it matters -->
<li class="nav-item me-lg-3 my-1 d-none" id="certLi"><a href="#" id="certWarn" class="badge bg-warning text-dark text-decoration-none" onclick="event.preventDefault();openCerts()"></a></li>
<li class="nav-item me-lg-3 my-1"><span id="phone" class="badge bg-secondary"><i class="fas fa-mobile-screen-button me-1"></i>Phone -</span></li>
<li class="nav-item me-lg-3 my-1"><span id="hours" class="badge bg-secondary"><i class="fas fa-clock me-1"></i>-</span></li>
<li class="nav-item"><span id="clock" class="navbar-text text-white font-monospace small">--:--:--</span></li>
</ul>
</div>
</nav>
<main class="container py-4">
<div id="phoneWait" class="alert alert-warning d-flex align-items-center d-none" role="status">
<div class="spinner-border spinner-border-sm me-3 flex-shrink-0" aria-hidden="true"></div>
<div><strong>Waiting for the phone to connect…</strong>
<div class="small text-muted mb-0">The operator handset isn't reachable yet - make sure it's powered on and joined to the office WiFi.</div></div>
</div>
<div class="row g-4">
<!-- LIVE CALL - persistent, real-time (screen-pop → on-call → wrap-up) -->
<div class="col-lg-5">
<h1 class="h5 mb-3 text-muted text-uppercase" style="letter-spacing:.5px"><i class="fas fa-phone-volume me-2"></i>Live call</h1>
<div id="stage"></div>
</div>
<!-- WORKSPACE - call archive + people, filtered by operator -->
<div class="col-lg-7">
<div class="d-flex align-items-center justify-content-between gap-2 flex-wrap mb-2">
<!-- operator filter (left) + view toggle (right) - both filter the archive AND the people list -->
<div class="btn-group btn-group-sm" role="group" id="opFilter" aria-label="Operator filter"></div>
<div class="btn-group btn-group-sm" role="group" id="wsTabs">
<button type="button" class="btn btn-primary" data-mode="archive" onclick="setView('archive')"><i class="fas fa-list me-1"></i>Call archive</button>
<button type="button" class="btn btn-outline-primary" data-mode="people" onclick="setView('people')"><i class="fas fa-address-book me-1"></i>People</button>
</div>
</div>
<!-- search: own row, full width, so the placeholder never gets clipped -->
<input id="wsSearch" class="form-control form-control-sm mb-3" placeholder="Search name, number, position, date, time…" oninput="onSearch()">
<div id="wsBody"></div>
</div>
</div>
<div class="text-end mt-3"><span class="text-muted small">Helpdesk</span></div>
</main>
<!-- toast -->
<div class="position-fixed bottom-0 start-50 translate-middle-x p-3" style="z-index:1080">
<div id="toast" class="toast align-items-center text-white bg-success border-0" role="alert">
<div class="d-flex"><div class="toast-body" id="toastBody"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-mdb-dismiss="toast"></button></div>
</div>
</div>
<!-- call detail (transcript, summary, notes) -->
<div class="modal fade" id="detail" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content" id="detailBody"></div></div>
</div>
<!-- resolutions-ledger conflict review: shows both conflicting decisions, operator sets the correct one -->
<div class="modal fade" id="conflict" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><div class="modal-content" id="conflictBody"></div></div>
</div>
<!-- certificate expiry detail, opened from the navbar warning -->
<div class="modal fade" id="certs" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Certificates expiring</h5>
<button type="button" class="btn-close" data-mdb-dismiss="modal"></button></div>
<div class="modal-body">
<div id="certBody"></div>
<div class="small text-muted border-top pt-2">More info: <code>docs/runbooks/cert-renewal.md</code></div>
</div>
<div class="modal-footer"><button class="btn btn-secondary btn-sm" data-mdb-dismiss="modal">Close</button></div>
</div></div>
</div>
<!-- add a resolution by hand (operator-recorded yes/no) -->
<div class="modal fade" id="addRes" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="arTitle">Record a resolution</h5>
<button type="button" class="btn-close" data-mdb-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label micro-label">What was decided</label>
<input id="arQuestion" class="form-control" autocomplete="off" placeholder="e.g. Access to the Ježlovice towers?"></div>
<div class="micro-label mb-2">Answer</div>
<div class="d-flex gap-2">
<button class="btn btn-success flex-fill" onclick="submitResolution('yes')">Allowed (YES)</button>
<button class="btn btn-danger flex-fill" onclick="submitResolution('no')">Forbidden (NO)</button>
</div>
<div class="small text-muted mt-3">Recorded against this caller and tagged as operator-set. If the same question already has an answer, this supersedes it.</div>
</div>
</div></div>
</div>
<!-- contact create / edit -->
<div class="modal fade" id="contactForm" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="cfTitle">New contact</h5>
<button type="button" class="btn-close" data-mdb-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label micro-label">Name</label>
<input id="cfName" class="form-control" autocomplete="off"></div>
<div class="mb-3"><label class="form-label micro-label">Phone numbers <span class="text-lowercase fw-normal">(comma-separated)</span></label>
<input id="cfNumbers" class="form-control" placeholder="+420…, 601 234 567" autocomplete="off"></div>
<div class="mb-3"><label class="form-label micro-label">Email</label>
<input id="cfEmail" type="email" class="form-control" autocomplete="off"></div>
<div class="row">
<div class="col-6 mb-3"><label class="form-label micro-label">Queue</label>
<select id="cfOperator" class="form-select"><option value="unrouted">Unrouted</option><option value="tmobile">T-Mobile</option><option value="vodafone">Vodafone</option></select></div>
<div class="col-6 mb-3"><label class="form-label micro-label">Position</label>
<input id="cfPosition" class="form-control" autocomplete="off" placeholder="e.g. Field technician"></div>
</div>
<div class="mb-1"><label class="form-label micro-label">Operator Notes</label>
<textarea id="cfContext" class="form-control" rows="2" placeholder="What to remember about this caller (optional)"></textarea></div>
</div>
<div class="modal-footer justify-content-between">
<button class="btn btn-outline-danger d-none" id="cfDelete" onclick="deleteContact()"><i class="fas fa-trash me-1"></i>Delete</button>
<div><button class="btn btn-secondary me-2" data-mdb-dismiss="modal">Cancel</button>
<button class="btn btn-primary" onclick="saveContact()"><i class="fas fa-floppy-disk me-1"></i>Save</button></div>
</div>
</div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/mdb-ui-kit@7.3.2/js/mdb.umd.min.js"></script>
<script>
const OPS={ tmobile:{label:'T-Mobile',cls:'op-tmobile',hex:'#e20074'}, vodafone:{label:'Vodafone',cls:'op-vodafone',hex:'#e60000'},
all:{label:'All',cls:'',hex:'#1266f1'}, unrouted:{label:'Unrouted',cls:'op-unrouted',hex:'#6c757d'} };
const $=s=>document.querySelector(s);
let queue='all', current=null, timer=null, watch=null, phase=null, acceptPending=false, acceptTimeout=null;
let view='archive', personId=null, archiveData=[], peopleData=[];
let curPerson=null, curDetail=null; // hold the rendered person/event so onclick handlers never interpolate user data (XSS)
let phSort={col:'when', dir:'desc'}; // person-detail call-history sort (When / State / Outcome)
const STATE_BADGE={ ringing:'bg-danger', answered:'bg-info', recording_uploaded:'bg-info',
transcribed:'bg-success', summarised:'bg-success', ended:'bg-secondary', recording_missing:'bg-warning',
failed:'bg-warning', no_speech:'bg-secondary' };
async function api(p,o){ const r=await fetch('/api/v1'+p,o); return r.ok?r.json():null; }
// reverse channel: the PC issues a call-control command; the real phone long-polls /device/commands,
// applies it via Telecom, and the resulting state returns through the call webhooks.
async function cmd(verb,arg){ if(!current)return null;
return api('/calls/'+current.call_id+'/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verb,arg})}); }
const opInfo=o=>OPS[o]||OPS.unrouted;
const pad=n=>String(n).padStart(2,'0');
function since(iso){ if(!iso) return ''; const d=(Date.now()-Date.parse(iso))/1000;
if(d<60)return'just now'; if(d<3600)return Math.floor(d/60)+'m ago'; if(d<86400)return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; }
const hhmm=iso=>iso?pad(new Date(iso).getHours())+':'+pad(new Date(iso).getMinutes()):'--:--';
const dmy=iso=>{ if(!iso)return''; const d=new Date(iso); return pad(d.getDate())+'.'+pad(d.getMonth()+1)+'. '+hhmm(iso); };
// unified search: match a date in several formats (ISO, dd.mm., d.m., dd.mm.yyyy) + the time (HH:MM and
// H:MM) so a typed date and/or time hits.
function dateTokens(iso){ if(!iso) return ''; const d=new Date(iso);
return [ iso.slice(0,10), pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.',
d.getDate()+'.'+(d.getMonth()+1)+'.', pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+d.getFullYear(),
hhmm(iso), d.getHours()+':'+pad(d.getMinutes()) ].join(' '); }
// one search box across fields: every whitespace-separated query token must appear in the haystack (AND).
function matchQ(hay, q){ if(!q) return true; const h=String(hay).toLowerCase();
return q.split(/\s+/).filter(Boolean).every(t=>h.includes(t)); }
const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const durS=s=>s?`${Math.floor(s/60)}m ${s%60}s`:'-';
const initials=n=>(n||'?').split(/\s+/).map(w=>w[0]).slice(0,2).join('').toUpperCase();
// operator filter (moved out of the navbar): All / T-Mobile / Vodafone - filters archive AND people
const FILTERS=[['all','All','fa-inbox'],['tmobile','T-Mobile','fa-building'],['vodafone','Vodafone','fa-building']];
function renderOpFilter(){ $('#opFilter').innerHTML=FILTERS.map(([k,label,icon])=>{
const on=queue===k;
const cls=on ? (k==='tmobile'?'btn op-tmobile':k==='vodafone'?'btn op-vodafone':'btn btn-primary') : 'btn btn-outline-secondary';
return `<button type="button" class="${cls}" onclick="setQueue('${k}')"><i class="fas ${icon} me-1"></i>${label}</button>`;
}).join(''); }
function setQueue(q){ queue=q; renderOpFilter(); if(!phase) renderStandby();
if(view==='archive') loadArchive(); else if(view==='people') loadPeople(); else if(view==='person'){/*keep*/} }
setInterval(()=>{ const t=new Date(); $('#clock').textContent=pad(t.getHours())+':'+pad(t.getMinutes())+':'+pad(t.getSeconds()); },1000);
async function pollStatus(){ const s=await api('/status'); if(!s)return; const el=$('#hours');
el.className='badge '+(s.business_open?'bg-success':'bg-warning');
el.innerHTML=`<i class="fas fa-clock me-1"></i>${s.business_open?'On shift':'Out of hours'} · ${(s.prague_time||'').split(' ')[1]||''}`;
renderCerts(s.certs); }
/* Certificate expiry warning. The vmin CA, the operator certs and the Whisper cert expire on the same
day and nothing renews them, so the failure is silent and takes out the console and transcription
together. Deliberately invisible while everything is in date - a badge that is always there stops
being read. grep anchor: renderCerts. */
let certList=[], certModal=null;
/* Never mention a certificate further out than this, whatever the backend's warning window is set to.
Both conditions matter: severity lets an operator TIGHTEN the window below 30 days, while this caps
how early we are willing to shout. Without the cap, a wide HELPDESK_CERT_WARN_DAYS produces
"3 certificates expiring" when the soonest is months away, and a warning nobody needs to act on is
how people learn to ignore the ones they do. */
const CERT_NEAR_DAYS = 30;
function renderCerts(certs){
certList = (certs||[]).filter(c=>c.severity!=='ok' && c.days_left<=CERT_NEAR_DAYS);
const li=$('#certLi'), a=$('#certWarn');
if(!li||!a) return;
if(!certList.length){ li.classList.add('d-none'); return; }
const worst = certList.some(c=>c.severity==='expired') ? 'expired'
: certList.some(c=>c.severity==='critical') ? 'critical' : 'warning';
a.className='badge text-decoration-none '+(worst==='warning'?'bg-warning text-dark':'bg-danger');
const c=certList[0]; // already sorted soonest-first by the backend
const label = c.days_left<0 ? `Certificate EXPIRED`
: certList.length>1 ? `${certList.length} certificates expiring`
: `Certificate expires in ${c.days_left} ${c.days_left===1?'day':'days'}`;
a.innerHTML=`<i class="fas fa-triangle-exclamation me-1"></i>${esc(label)}`;
li.classList.remove('d-none');
}
function openCerts(){
if(!certList.length) return;
$('#certBody').innerHTML = certList.map(c=>{
const cls = c.severity==='warning' ? 'text-warning' : 'text-danger';
const when = c.days_left<0 ? `expired ${Math.abs(c.days_left)} ${Math.abs(c.days_left)===1?'day':'days'} ago`
: `${c.days_left} ${c.days_left===1?'day':'days'} left`;
return `<div class="mb-3">
<div><strong>${esc(c.name)}</strong> <span class="${cls}">${esc(when)}</span></div>
<div class="small text-muted">Expires ${esc((c.expires_at||'').replace('T',' ').replace('Z',' UTC'))}</div>
<div class="small">When it lapses: ${esc(c.what_breaks||'')}</div></div>`;
}).join('');
certModal = certModal || new mdb.Modal($('#certs'));
certModal.show();
}
async function pollDevice(){ const s=await api('/device/status'); const up=!!(s&&s.connected);
const b=$('#phone'); if(b){ b.className='badge '+(up?'bg-success':'bg-warning text-dark');
b.innerHTML=`<i class="fas fa-mobile-screen-button me-1"></i>${up?'Phone connected':'Waiting for phone…'}`; }
const w=$('#phoneWait'); if(w) w.classList.toggle('d-none', up); }
function badge(op){ const i=opInfo(op); return `<span class="badge ${i.cls||'bg-primary'}">${i.label}</span>`; }
function resBadge(r){ return r?`<span class="badge ${r==='solved'?'bg-success':r==='unsolved'?'bg-danger':'bg-warning'}">${r}</span>`:`<span class="badge bg-light text-dark border">-</span>`; }
/* ======================= LIVE CALL (into #stage) ======================= */
function stopTimers(){ if(timer){clearInterval(timer);timer=null;} if(watch){clearInterval(watch);watch=null;} }
function renderStandby(){ current=null; phase=null; acceptPending=false; clearTimeout(acceptTimeout); stopTimers();
$('#stage').innerHTML=`<div class="card"><div class="card-body stage-min d-flex flex-column justify-content-center align-items-center text-center text-muted">
<i class="fas fa-phone-slash fa-2x mb-3 opacity-50"></i>
<div class="h6 mb-1">Standby</div>
<div class="small mb-3">The caller's card appears here when the phone rings.</div>
<div class="input-group input-group-sm" style="max-width:320px">
<span class="input-group-text"><i class="fas fa-phone"></i></span>
<input id="dialNum" class="form-control" type="tel" placeholder="Call a number…" autocomplete="off"
onkeydown="if(event.key==='Enter')dialFromInput()">
<button class="btn btn-success" onclick="dialFromInput()">Call</button>
</div>
<div class="small mt-1" style="font-size:.72rem">Places the call on the office Pixel - pick up the handset to talk.</div>
</div></div>`; }
// click-to-dial: ask the office phone to place an outgoing call. The call then flows back through the
// normal /calls/outgoing path (screen-pop + recording), so this card flips to the live view on its own.
async function dialNumber(num){ num=(num||'').trim(); if(!num){ return; }
const r=await api('/dial',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({number:num})});
if(r&&r.command_id){ toast('Dialing '+(r.number||num)+' on the Pixel…'); }
else { toast(r&&r.error==='no_device'?'No phone connected to dial from':'Could not place the call'); } }
function dialFromInput(){ const el=$('#dialNum'); if(el){ dialNumber(el.value); el.value=''; } }
const OUTCOMES=[['solved','Solved','success','fa-check'],['partial','Partial','warning','fa-circle-half-stroke'],['unsolved','Unsolved','danger','fa-xmark']];
function outcomeButtons(c){ const chosen=c._resolution;
return OUTCOMES.map(([k,label,color,icon])=>{ const sel=chosen===k;
return `<button type="button" data-r="${k}" onclick="setRes('${k}')"
class="btn flex-fill py-2 fw-${sel?'bold':'normal'} ${sel?`btn-${color} outcome-sel`:`btn-outline-${color} ${chosen?'opacity-50':''}`}">
<i class="fas ${sel?'fa-circle-check':icon} me-1"></i>${label}</button>`; }).join(''); }
function outcomeSection(c){ return `<div class="mb-3">
<label class="form-label micro-label">Outcome - how did it go?</label>
<div class="d-flex gap-2" id="res">${outcomeButtons(c)}</div></div>`; }
function notesSection(c){ return `<div class="mb-3">
<label class="form-label micro-label">Call notes</label>
<textarea id="notes" class="form-control" rows="3" placeholder="What is the technician calling about? What did you advise?">${esc(c._notes||'')}</textarea></div>`; }
function renderCall(c, ph){ current=c; phase=ph;
const op=c.person?c.person.operator:c.operator; const unknown=c.unknown_caller;
const name=unknown?'Unknown caller':((c.person&&c.person.name)||'-');
const hist=(c.recent_events||[]).filter(e=>e.summary_line||e.resolution);
const outb = c.direction==='outbound';
const chip = ph==='ringing' ? (outb
? `<span class="badge bg-info pulse"><i class="fas fa-arrow-up me-1"></i>Outgoing - ringing…</span>`
: `<span class="badge bg-danger pulse"><i class="fas fa-phone-volume me-1"></i>Incoming</span>`)
: ph==='oncall' ? `<span class="badge bg-success" id="chip"><i class="fas fa-phone me-1"></i>On call</span>`
: `<span class="badge bg-warning text-dark"><i class="fas fa-pen-to-square me-1"></i>Wrap-up</span>`;
const accent = ph==='wrapup' ? '#ffa900' : opInfo(op).hex;
$('#stage').innerHTML=`
<div class="card call-card" style="--op:${accent}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>${badge(op)} ${c.campaign?`<span class="badge bg-light text-dark ms-1">${esc(c.campaign)}</span>`:''}</div>
${chip}
</div>
<h2 class="h3 mb-0 ${unknown?'text-warning':''}">${esc(name)}</h2>
<div class="text-muted mb-2">${unknown
? `not in CRM - <a href="#" onclick="event.preventDefault();openContactFromNumber(current&&current.number)">create a contact</a>`
: esc((c.person&&c.person.position)||'')}</div>
<div class="num-lg mb-3">${esc(c.number||'withheld')}</div>
<div class="row text-muted small mb-3">
<div class="col-auto"><span class="micro-label">DID</span><br><code class="serial">${esc(c.dialed_did||'-')}</code></div>
<div class="col-auto"><span class="micro-label">call-id</span><br><code class="serial">${esc((c.call_id||'').slice(0,8))}</code></div>
</div>
${ph==='wrapup'?`<div class="alert alert-warning py-2 px-3 small mb-3"><i class="fas fa-circle-info me-1"></i><b>Call ended</b> (${esc(c._endedBy||'hung up')}) - finish your notes &amp; set the outcome, then file it. Nothing is lost.</div>`:''}
<div class="micro-label mb-1">History with this caller</div>
${hist.length?`<ul class="history-list mb-3">${hist.map(e=>`<li>
<span class="text-muted" style="min-width:78px">${since(e.at)}</span>
<span class="flex-grow-1">${esc(e.summary_line||'(call, no summary)')}</span>
${e.resolution?`<span class="badge ${e.resolution==='solved'?'bg-success':e.resolution==='unsolved'?'bg-danger':'bg-warning'}">${e.resolution}</span>`:''}</li>`).join('')}</ul>`
:`<p class="text-muted fst-italic small mb-3">${unknown?'No prior contact.':'First recorded call with this person.'}</p>`}
${ph==='ringing'?'':notesSection(c)+outcomeSection(c)}
<div class="d-flex align-items-center gap-2 flex-wrap pt-1">
${ph==='ringing'
? (outb
? `<span class="text-muted small me-auto"><i class="fas fa-circle-info me-1"></i>Dialing from the office <b>Pixel</b> - this card flips to “On&nbsp;call” when they pick up.</span>`
: `<span class="text-muted small me-auto"><i class="fas fa-circle-info me-1"></i>Or answer on the office <b>Pixel</b> - either way this card flips to “On&nbsp;call”.</span>
<button class="btn btn-outline-danger" onclick="decline()"><i class="fas fa-phone-slash me-2"></i>Decline</button>
<button id="acceptBtn" class="btn btn-primary answer-glow" onclick="answer()"><i class="fas fa-phone me-2"></i>Accept on PC</button>`)
: ph==='oncall'
? `<button id="muteBtn" class="btn btn-outline-secondary btn-sm" onclick="muteToggle()"><i class="fas fa-microphone me-1"></i>Mute</button>
<button id="holdBtn" class="btn btn-outline-secondary btn-sm" onclick="holdToggle()"><i class="fas fa-pause me-1"></i>Hold</button>
<span class="me-auto"></span>
<button class="btn btn-danger" onclick="endCall()"><i class="fas fa-phone-slash me-2"></i>End call</button>`
: `<span class="text-muted small me-auto"><i class="fas fa-cloud-arrow-up me-1"></i>Notes &amp; outcome save automatically.</span>
<button class="btn btn-success btn-lg" onclick="closeWrap()"><i class="fas fa-check me-2"></i>File &amp; close</button>`}
</div>
</div>
</div>`;
if(ph!=='ringing'){ const ta=$('#notes');
if(ta){ let t; ta.oninput=()=>{ c._notes=ta.value; clearTimeout(t);
t=setTimeout(()=>api('/events/'+c.call_id+'/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({notes:ta.value})}),500); };
if(ph==='wrapup') ta.focus(); } }
if(ph==='ringing'){ // not accepted on the PC yet - watch for the call being answered on the office Pixel
if(watch){clearInterval(watch);watch=null;}
watch=setInterval(async()=>{ if(phase!=='ringing'||!current)return;
const ev=await api('/events/'+current.call_id); if(!ev)return;
if(ev.answered_at){ enterOnCall(); return; }
if(ev.ended_at){ stopTimers(); toast(ev.direction==='outbound'?'Not answered':'Missed - caller hung up'); renderStandby(); refreshWorkspace(); }
},1500); }
}
// Accept on PC: send the answer command to the phone, then WAIT for the phone to actually pick up
// (the ringing watcher flips us to On call when /calls/answered arrives). Never fake "On call" - the
// phone might be ringing still. Show a pending state and time out to "didn't pick up".
async function answer(){
if(!current||acceptPending) return;
acceptPending=true;
const reset=()=>{ acceptPending=false; clearTimeout(acceptTimeout);
const x=$('#acceptBtn'); if(x){ x.disabled=false; x.innerHTML='<i class="fas fa-phone me-2"></i>Accept on PC'; } };
const b=$('#acceptBtn'); if(b){ b.disabled=true; b.innerHTML='<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>Answering on the phone…'; }
const r=await cmd('answer');
if(r===null){ reset(); toast('Could not reach the phone - try again, or answer on the Pixel'); return; }
// do NOT enterOnCall here; the ringing watcher does it only once the phone confirms it answered.
acceptTimeout=setTimeout(()=>{ if(acceptPending&&phase==='ringing'){
reset(); toast('Phone did not pick up - it is still ringing. Try again, or answer on the Pixel.'); } }, 15000);
}
function enterOnCall(){ if(!current)return; acceptPending=false; clearTimeout(acceptTimeout); stopTimers();
renderCall(current,'oncall'); const t0=Date.now();
timer=setInterval(()=>{ const el=$('#chip'); if(!el)return; const s=Math.floor((Date.now()-t0)/1000);
el.innerHTML=`<i class="fas fa-phone me-1"></i>On call · ${pad(Math.floor(s/60))}:${pad(s%60)}`; },1000);
watch=setInterval(async()=>{ if(phase!=='oncall'||!current)return;
const ev=await api('/events/'+current.call_id); if(ev&&ev.ended_at) goWrap('the caller hung up'); },1500); }
async function setRes(r){ if(!current)return; current._resolution=r;
const g=$('#res'); if(g) g.innerHTML=outcomeButtons(current);
await api('/events/'+current.call_id+'/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolution:r})}); }
function goWrap(by){ if(!current)return; stopTimers(); current._endedBy=by||'hung up'; renderCall(current,'wrapup'); }
async function endCall(){ if(!current)return; await cmd('hangup'); goWrap('you ended it'); }
async function muteToggle(){ if(!current)return; current.muted=!current.muted;
await cmd(current.muted?'mute':'unmute'); reflect('#muteBtn',current.muted,'fa-microphone-slash','Muted','fa-microphone','Mute'); }
async function holdToggle(){ if(!current)return; current.on_hold=!current.on_hold;
await cmd(current.on_hold?'hold':'resume'); reflect('#holdBtn',current.on_hold,'fa-play','Resume','fa-pause','Hold'); }
function reflect(sel,on,onIcon,onLbl,offIcon,offLbl){ const b=$(sel); if(!b)return;
b.classList.toggle('btn-warning',on); b.classList.toggle('btn-outline-secondary',!on);
b.innerHTML=`<i class="fas ${on?onIcon:offIcon} me-1"></i>${on?onLbl:offLbl}`; }
async function decline(){ if(!current)return; await cmd('reject'); stopTimers(); toast('Call declined'); renderStandby(); refreshWorkspace(); }
function closeWrap(){
toast('Call filed'+(current&&current._resolution?` · ${current._resolution}`:' · no outcome set'));
renderStandby(); refreshWorkspace(); }
async function pollPop(){ if(phase==='oncall'||phase==='wrapup')return;
const d=await api('/screen-pop?operator='+queue); if(!d||!d.messages)return;
const pop=d.messages[d.messages.length-1]; if(pop&&(!current||current.call_id!==pop.call_id)) renderCall(pop,'ringing'); }
/* ======================= WORKSPACE: archive + people (into #wsBody) ======================= */
function setView(v){ view=v; personId=null;
document.querySelectorAll('#wsTabs button').forEach(b=>{ const on=b.dataset.mode===v;
b.classList.toggle('btn-primary',on); b.classList.toggle('btn-outline-primary',!on); });
const s=$('#wsSearch'); s.classList.remove('d-none'); s.value=''; s.placeholder = 'Search name, number, position, date, time…';
if(v==='archive') loadArchive(); else loadPeople();
}
function onSearch(){ if(view==='archive') renderArchive(); else if(view==='people') renderPeople(); }
function refreshWorkspace(){ if(view==='archive') loadArchive(); else if(view==='people') loadPeople(); }
async function loadArchive(){ const d=await api('/events?operator='+queue+'&limit=200'); archiveData=(d&&d.events)||[]; if(view==='archive') renderArchive(); }
// missed = an inbound call that ended without ever being answered (caller hung up, we rejected it, or
// the ring watchdog closed it) - rendered as a red row so unanswered callers stand out in history.
const isMissed=e=>!e.answered_at && !!e.ended_at && (e.direction||'inbound')==='inbound';
// direction marker: outgoing calls get an ↑ arrow before the name (inbound stays unmarked - it's the norm)
const dirIcon=e=>e.direction==='outbound'?`<i class="fas fa-arrow-up text-info me-1" title="Outgoing call"></i>`:'';
function stateBadge(e){ return isMissed(e)
? `<span class="badge bg-danger"><i class="fas fa-phone-slash me-1"></i>missed</span>`
: `<span class="badge ${STATE_BADGE[e.state]||'bg-secondary'}">${(e.state||'').replace('_',' ')}</span>`; }
function callRow(e){ return `<tr class="${isMissed(e)?'table-danger':''}" style="cursor:pointer" onclick='loadEvent(${JSON.stringify(e.call_id)})'>
<td><small class="text-muted">${dmy(e.started_at)}</small></td>
<td><div>${dirIcon(e)}${esc(e.person_name||e.number||'unknown')}</div>
<small class="text-muted">${e.person_name?esc(e.number||''):''}</small></td>
<td>${badge(e.operator)}</td>
<td><small>${e.duration_s?durS(e.duration_s):'-'}</small></td>
<td>${stateBadge(e)}</td>
<td>${resBadge(e.resolution)}</td></tr>`; }
function renderArchive(){ const q=($('#wsSearch').value||'').toLowerCase().trim();
const rows=archiveData.filter(e=>matchQ([e.person_name, e.number, e.position, opInfo(e.operator).label, dateTokens(e.started_at)].filter(Boolean).join(' '), q));
$('#wsBody').innerHTML=`<div class="card"><div class="card-body p-0">
<table class="table table-hover align-middle mb-0">
<thead><tr><th>When</th><th>Caller</th><th>Queue</th><th>Dur</th><th>State</th><th>Outcome</th></tr></thead>
<tbody>${rows.map(callRow).join('')||`<tr><td colspan="6" class="text-center text-muted p-4">No calls${q?' match your search':' yet'} in ${opInfo(queue).label}.</td></tr>`}</tbody>
</table></div></div>
<div class="small text-muted mt-2">${rows.length} call${rows.length===1?'':'s'}${q?` matching “${esc(q)}`:''} · ${opInfo(queue).label}</div>`; }
async function loadPeople(){ const d=await api('/people'); peopleData=(d&&d.people)||[]; if(view==='people') renderPeople(); }
function personRow(p){ const hex=opInfo(p.operator).hex;
return `<div class="d-flex align-items-center gap-3 p-3 border-bottom person-row" onclick="openPerson(${p.id})">
<div class="avatar" style="background:${hex}">${esc(initials(p.name))}</div>
<div class="flex-grow-1" style="min-width:0">
<div class="fw-bold text-truncate">${esc(p.name)} ${p.has_context?'<i class="fas fa-note-sticky text-warning ms-1" title="Has context"></i>':''}</div>
<small class="text-muted">${badge(p.operator)} ${p.position?esc(p.position):''} · ${esc((p.numbers||[])[0]||'')}${p.email?' · '+esc(p.email):''}</small>
</div>
<div class="text-end small text-muted flex-shrink-0">
<div><span class="badge bg-light text-dark border">${p.call_count} call${p.call_count===1?'':'s'}</span></div>
<div>${p.last_call_at?since(p.last_call_at):'no calls'}</div>
</div>
<i class="fas fa-chevron-right text-muted"></i></div>`; }
function renderPeople(){ const q=($('#wsSearch').value||'').toLowerCase().trim();
const rows=peopleData
.filter(p=>queue==='all' || p.operator===queue) // operator filter
.filter(p=>matchQ([p.name, (p.numbers||[]).join(' '), p.position, p.email, dateTokens(p.last_call_at)].filter(Boolean).join(' '), q));
$('#wsBody').innerHTML=`
<div class="d-flex align-items-center mb-2">
<span class="small text-muted">${rows.length} contact${rows.length===1?'':'s'}${queue!=='all'?' · '+opInfo(queue).label:''}${q?` matching “${esc(q)}`:''}</span>
<button class="btn btn-sm btn-primary ms-auto" onclick="openContactForm(null)"><i class="fas fa-plus me-1"></i>New contact</button>
</div>
<div class="card"><div class="card-body p-0">
${rows.map(personRow).join('')||`<div class="text-center text-muted p-4">No people${q?' match your search':(queue!=='all'?' in '+opInfo(queue).label:' yet')}. <a href="#" onclick="event.preventDefault();openContactForm(null)">Create one</a>.</div>`}
</div></div>`; }
async function openPerson(id){ view='person'; personId=id; $('#wsSearch').classList.add('d-none');
const p=await api('/people/'+id); if(!p){ toast('Person not found'); setView('people'); return; } renderPerson(p); }
function renderPerson(p){ curPerson=p; const hex=opInfo(p.operator).hex; const evs=p.events||[];
$('#wsBody').innerHTML=`
<div class="card mb-3" style="border-left:4px solid ${hex}">
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3">
<button class="btn btn-sm btn-outline-secondary" onclick="setView('people')"><i class="fas fa-arrow-left"></i></button>
<div class="avatar" style="background:${hex}">${esc(initials(p.name))}</div>
<div class="flex-grow-1" style="min-width:0">
<h2 class="h4 mb-0 text-truncate">${esc(p.name)}</h2>
<div class="small text-muted">${badge(p.operator)} ${p.position ? esc(p.position)
: (p.suggested_position ? `<span class="text-warning"><i class="fas fa-robot me-1"></i>${esc(p.suggested_position)}</span> <a href="#" onclick="event.preventDefault();acceptPosition()">accept</a> <span class="badge bg-light text-secondary border ms-1" style="font-size:.6rem">AI suggestion</span>` : '')}</div>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="openContactForm(curPerson)"><i class="fas fa-pen me-1"></i>Edit</button>
</div>
<div class="row small mb-3">
<div class="col-sm-6 mb-2"><span class="micro-label">Numbers</span><br>
${(p.numbers||[]).map(n=>`<span class="text-nowrap me-2"><code class="serial">${esc(n)}</code>
<button class="btn btn-sm btn-outline-success py-0 px-1 ms-1" title="Call on the office Pixel" onclick='dialNumber(${JSON.stringify(n)})'><i class="fas fa-phone"></i></button></span>`).join('')||'<span class="text-muted">none</span>'}</div>
<div class="col-sm-6 mb-2"><span class="micro-label">Email</span><br>
${p.email?`<a href="mailto:${esc(p.email)}">${esc(p.email)}</a>`:'<span class="text-muted">none</span>'}</div>
</div>
<div class="ctx-box">
<label class="form-label micro-label">
<i class="fas fa-note-sticky me-1"></i>Operator Notes</label>
<textarea id="ctx" class="form-control" rows="3" placeholder="Persistent notes about this caller - region, kit, preferences, recurring issues. Shown on every future call.">${esc(p.context||'')}</textarea>
<button class="btn btn-sm btn-primary mt-2" id="notesConfirm" onclick="confirmNotes()">
<i class="fas fa-check me-1"></i>Confirm &amp; refresh context</button>
<div class="small text-muted mt-1" id="ctxStatus">Saved automatically.</div>
</div>
</div>
</div>
<div class="card mt-3"><div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="fw-bold"><i class="fas fa-robot me-1"></i>Context</span>
<span id="ctxPending" class="badge bg-info ms-2 d-none">refreshing…</span>
<a href="#" class="ms-2 small" onclick="event.preventDefault();rebuildContext()">regenerate</a>
<input id="ctxSearch" class="form-control form-control-sm ms-auto" style="max-width:220px"
placeholder="Search context…" oninput="renderContext(curPerson)">
</div>
<div id="ctxPanel"></div>
</div></div>
<div class="card mt-3"><div class="card-body p-0">
<div class="px-3 pt-3 pb-2 d-flex align-items-center gap-2 flex-wrap">
<span class="micro-label"><i class="fas fa-clock-rotate-left me-1"></i>Call history</span>
<input id="phSearch" class="form-control form-control-sm ms-auto" style="max-width:220px" placeholder="Search - date, time, summary…" oninput="onPersonHistorySearch()">
</div>
<table class="table table-hover align-middle mb-0">
<thead><tr>
<th style="cursor:pointer" onclick="sortHistory('when')">When <span id="phArrowwhen" class="text-muted"></span></th>
<th>Summary</th>
<th style="cursor:pointer" onclick="sortHistory('state')">State <span id="phArrowstate" class="text-muted"></span></th>
<th style="cursor:pointer" onclick="sortHistory('outcome')">Outcome <span id="phArrowoutcome" class="text-muted"></span></th>
</tr></thead>
<tbody id="phHistory">${historyRows(evs,'')}</tbody>
</table></div></div>`;
const ta=$('#ctx'); let t;
ta.oninput=()=>{ $('#ctxStatus').textContent='Saving…'; clearTimeout(t);
t=setTimeout(async()=>{ await api('/people/'+p.id+'/context',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({context:ta.value})});
$('#ctxStatus').textContent='Saved.'; },500); };
updateSortArrows(); // reflect the active sort column/direction in the header
renderContext(curPerson);
}
// call-history rows for the person profile: filtered by the unified matcher, then sorted by phSort.
const OUTCOME_RANK={ solved:'0', partial:'1', unsolved:'2' }; // meaningful outcome order (unset last)
function hSortKey(e){
if(phSort.col==='state') return (e.state||'');
if(phSort.col==='outcome') return (OUTCOME_RANK[e.resolution]||'9');
return (e.started_at||''); // 'when' - ISO strings sort chronologically
}
function historyRows(evs, q){
const rows=(evs||[]).filter(e=>matchQ([e.ai_summary, e.notes, e.state, e.resolution, dateTokens(e.started_at)].filter(Boolean).join(' '), q));
rows.sort((a,b)=>{ const ka=hSortKey(a), kb=hSortKey(b); const c=ka<kb?-1:ka>kb?1:0; return phSort.dir==='asc'?c:-c; });
return rows.map(e=>`<tr class="${isMissed(e)?'table-danger':''}" style="cursor:pointer" onclick='loadEvent(${JSON.stringify(e.call_id)})'>
<td><small class="text-muted">${dirIcon(e)}${dmy(e.started_at)}</small></td>
<td><small>${esc((e.ai_summary||e.notes||'').slice(0,90)||'-')}</small></td>
<td>${stateBadge(e)}</td>
<td>${resBadge(e.resolution)}</td></tr>`).join('')
|| `<tr><td colspan="4" class="text-center text-muted p-4">No calls${q?' match your search':' from this caller yet'}.</td></tr>`;
}
function sortHistory(col){
if(phSort.col===col) phSort.dir = phSort.dir==='asc'?'desc':'asc';
else phSort={col, dir: col==='when'?'desc':'asc'}; // When defaults newest-first; State/Outcome A→Z
onPersonHistorySearch(); updateSortArrows();
}
function updateSortArrows(){ ['when','state','outcome'].forEach(c=>{ const el=$('#phArrow'+c);
if(el) el.textContent = phSort.col===c ? (phSort.dir==='asc'?'▲':'▼') : ''; }); }
function onPersonHistorySearch(){ if(!curPerson)return;
const t=$('#phHistory'); if(t) t.innerHTML = historyRows(curPerson.events||[], ($('#phSearch').value||'').toLowerCase().trim()); }
/* ======================= CONTACT CREATE / EDIT ======================= */
let contactModal=null, cfEditId=null;
function openContactForm(p){ cfEditId=(p&&p.id)||null;
$('#cfTitle').textContent = cfEditId?'Edit contact':'New contact';
$('#cfName').value=(p&&p.name)||'';
$('#cfNumbers').value=(p&&(p.numbers||[]).join(', '))||'';
$('#cfEmail').value=(p&&p.email)||'';
$('#cfOperator').value=(p&&p.operator&&p.operator!=='all')?p.operator:(queue!=='all'?queue:'unrouted');
$('#cfPosition').value=(p&&p.position)||'';
$('#cfContext').value=(p&&p.context)||'';
$('#cfDelete').classList.toggle('d-none', !cfEditId); // delete only makes sense when editing
contactModal=contactModal||new mdb.Modal($('#contactForm')); contactModal.show();
setTimeout(()=>$('#cfName').focus(),300); }
function openContactFromNumber(num){ openContactForm({numbers:num?[num]:[]}); }
async function saveContact(){
const body={ name:$('#cfName').value.trim(),
numbers:$('#cfNumbers').value.split(',').map(s=>s.trim()).filter(Boolean),
email:$('#cfEmail').value.trim(), operator:$('#cfOperator').value,
position:$('#cfPosition').value.trim(), context:$('#cfContext').value };
if(!body.name){ toast('Name is required'); return; }
const r = cfEditId
? await api('/people/'+cfEditId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
: await api('/people',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
if(!r||r.error){ toast('Save failed'+(r&&r.error?': '+r.error.replace(/_/g,' '):'')); return; }
if(contactModal) contactModal.hide();
toast(cfEditId?'Contact updated':'Contact created');
setView('people'); openPerson(r.id); }
async function deleteContact(){ if(!cfEditId)return;
if(!confirm('Delete this contact? Their past calls are kept, but will show as an unknown caller.')) return;
const r=await api('/people/'+cfEditId+'/delete',{method:'POST'}); // POST-as-delete (WEBrick has no do_DELETE)
if(contactModal) contactModal.hide();
toast(r?'Contact deleted':'Delete failed'); setView('people'); }
// accept the AI-suggested position for the open caller (reads curPerson - no onclick interpolation)
async function acceptPosition(){ if(!curPerson||!curPerson.suggested_position)return;
const pos=curPerson.suggested_position;
await api('/people/'+curPerson.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({position:pos})});
toast('Position set · '+pos); openPerson(curPerson.id); }
/* ======================= CALL DETAIL MODAL ======================= */
let detailModal=null;
function fmtTranscript(t, state){
if(!t || !t.trim()){
if(state==='no_speech') return `<p class="text-muted fst-italic mb-0"><i class="fas fa-volume-xmark me-1"></i>No speech detected on the recording - nothing to transcribe.</p>`;
if(state==='failed') return `<p class="text-danger mb-0"><i class="fas fa-xmark me-1"></i>Transcription failed - see the reason above.</p>`;
if(state==='recording_missing') return `<p class="text-warning mb-0"><i class="fas fa-triangle-exclamation me-1"></i>Recording never arrived from the phone.</p>`;
return `<p class="text-muted fst-italic mb-0">Not transcribed yet - the transcript appears a few minutes after the call, once the recording has run through Whisper.</p>`;
}
return t.trim().split('\n').map(l=>{ const m=l.match(/^\[Speaker (\d+)\]:\s*(.*)$/);
if(!m) return `<div>${esc(l)}</div>`;
const s=+m[1]; return `<div class="mb-2 d-flex"><span class="badge ${s===1?'bg-primary':'bg-secondary'} me-2 align-self-start">S${s}</span><span>${esc(m[2])}</span></div>`; }).join(''); }
function renderDetail(e){ curDetail=e; const A=e.action_items||[];
$('#detailBody').innerHTML=`
<div class="modal-header">
<div><h5 class="modal-title mb-0">${esc(e.person_name||'Unknown caller')}</h5>
<div class="small text-muted">${badge(e.operator)} ${e.campaign?`<span class="badge bg-light text-dark">${esc(e.campaign)}</span>`:''} · <code class="serial">${esc(e.number||'withheld')}</code></div></div>
<button type="button" class="btn-close" data-mdb-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2 small text-muted mb-3">
<div class="col-6 col-md-3"><div class="micro-label">When</div>${dmy(e.started_at)}</div>
<div class="col-6 col-md-3"><div class="micro-label">Duration</div>${durS(e.duration_s)}</div>
<div class="col-6 col-md-3"><div class="micro-label">State</div><span class="badge ${STATE_BADGE[e.state]||'bg-secondary'}">${(e.state||'').replace('_',' ')}</span></div>
<div class="col-6 col-md-3"><div class="micro-label">Outcome</div>${resBadge(e.resolution)}</div>
</div>
${(e.state==='failed'||e.state==='recording_missing')?`<div class="alert alert-danger py-2 px-3 small mb-3"><i class="fas fa-triangle-exclamation me-1"></i><b>Processing failed:</b> ${esc(e.failure_reason||'unknown error')}</div>`:''}
<div class="mb-3">${e.person_id
? `<button class="btn btn-sm btn-outline-primary" onclick="fromDetailToPerson(${e.person_id})"><i class="fas fa-address-card me-1"></i>Open caller profile</button>`
: `<button class="btn btn-sm btn-outline-primary" onclick="fromDetailCreate()"><i class="fas fa-user-plus me-1"></i>Create contact from this number</button>`}</div>
${e.notes?`<div class="mb-3"><div class="micro-label mb-1">Operator notes</div><div class="border rounded p-2 bg-light">${esc(e.notes)}</div></div>`:''}
${e.ai_summary?`<div class="mb-3"><div class="micro-label mb-1"><i class="fas fa-robot me-1"></i>AI summary <span class="badge bg-light text-dark border ms-1">advisory</span></div><div>${esc(e.ai_summary)}</div></div>`:''}
${A.length?`<div class="mb-3"><div class="micro-label mb-1">Suggested action items <span class="badge bg-light text-dark border ms-1">advisory · humans decide</span></div>
<ul class="mb-0">${A.map(a=>`<li>${esc(a.text)}${a.target?` <span class="badge bg-secondary ms-1">${esc(a.target)}</span>`:''}</li>`).join('')}</ul></div>`:''}
<div><div class="micro-label mb-1"><i class="fas fa-file-lines me-1"></i>Transcript</div>
<div class="border rounded p-3 bg-light" style="max-height:42vh;overflow:auto">${fmtTranscript(e.transcript, e.state)}</div></div>
${e.recording_url?`<div class="mt-2 small text-muted"><i class="fas fa-headphones me-1"></i>Recording: <code class="serial">${esc(e.recording_url)}</code>
<a class="ms-2 text-decoration-none" href="${esc(e.recording_url)}" download title="Download recording"><i class="fas fa-download"></i></a></div>`:''}
</div>
<div class="modal-footer py-2"><button class="btn btn-secondary" data-mdb-dismiss="modal">Close</button></div>`; }
async function loadEvent(id){ const e=await api('/events/'+id); if(!e)return; renderDetail(e);
detailModal=detailModal||new mdb.Modal($('#detail')); detailModal.show(); }
function fromDetailToPerson(pid){ if(detailModal) detailModal.hide(); setView('people'); openPerson(pid); }
function fromDetailCreate(){ if(detailModal) detailModal.hide(); openContactFromNumber(curDetail&&curDetail.number); }
let toastObj=null;
function toast(msg){ $('#toastBody').textContent=msg;
toastObj=toastObj||new mdb.Toast($('#toast')); toastObj.show(); }
/* ======================= CONTEXT PANEL ======================= */
async function confirmNotes(){
if(!curPerson) return;
const ta = $('#ctx');
// flush the pending debounced note save so the rebuild sees the latest text
await api('/people/'+curPerson.id+'/context',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({context:ta.value})});
await api('/people/'+curPerson.id+'/notes/confirm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({context:ta.value})});
toast('Context rebuild started…'); pollContext(curPerson.id);
}
function renderContext(p){
if(!p) return; const q = ($('#ctxSearch')?.value||'');
const ac = p.ai_context||{summary:'',other_context:[]};
const pinned = p.ai_context_pinned_sections||{};
const line = (t)=>`<div>${esc(t)}</div>`;
const secHdr = (title,section)=>`<div class="d-flex align-items-center">
<span class="micro-label">${esc(title)}</span>
${pinned[section]!==undefined
? `<span class="badge bg-warning text-dark ms-2" title="Operator-edited">pinned</span>
<a href="#" class="ms-1 small" onclick="event.preventDefault();releaseSection('${section}')">release</a>`
: `<a href="#" class="ms-2 small" onclick="event.preventDefault();editSection('${section}')"><i class="fas fa-pen"></i></a>`}</div>`;
// Summary
let html = secHdr('Summary','summary') + line(ac.summary||'-');
// derived recently-* (from resolution enum - never contradicts badges)
const recent = (title,rows)=> (rows&&rows.length)
? `<div class="mt-2"><span class="micro-label">${esc(title)}</span>`
+ rows.filter(r=>matchQ((r.line||''),q)).map(r=>`<div class="small">${esc(r.line||'')} <span class="text-muted">${esc(r.at||'')}</span></div>`).join('') + '</div>' : '';
html += recent('Recently solved',p.recently_solved) + recent('Recently unsolved',p.recently_unsolved) + recent('Recently partial',p.recently_partial);
// resolutions ledger (yes/no) with contradiction chip + resolve
const active = (p.resolutions_ledger||[]).filter(e=>!e.superseded_by).filter(e=>matchQ((e.question||'')+' '+(e.decision||''),q));
html += `<div class="mt-2"><div class="d-flex align-items-center">
<span class="micro-label">Previous resolutions</span>
<a href="#" class="ms-2 small" title="Record a yes/no decision by hand" onclick="event.preventDefault();openAddResolution()">+ add</a></div>`
+ (active.length
? active.map(e=>`<div class="small">
<span class="badge ${e.decision==='no'?'bg-danger':e.decision==='yes'?'bg-success':'bg-secondary'}">${esc((e.decision||'').toUpperCase())}</span>
${esc(e.question||'')}
${e.source==='operator'?`<span class="badge bg-light text-dark border ms-1" title="${e.by?'Set by '+esc(e.by):'Set by an operator'}">${e.by?esc(e.by):'operator'}</span>`:''}
<a href="#" class="ms-1 text-muted" title="Edit this resolution" onclick="event.preventDefault();openEditResolution('${esc(e.id)}')"><i class="fas fa-pen"></i></a>
${e.contradiction?`<span class="badge bg-warning text-dark ms-1" title="This caller was given conflicting answers over time">⚠ conflict</span>
<a href="#" class="ms-1" onclick="event.preventDefault();openConflict('${esc(e.id)}')">review</a>`:''}</div>`).join('')
: `<div class="small text-muted">none yet</div>`)
+ '</div>';
// other context
const oc = (ac.other_context||[]).filter(t=>matchQ(t,q));
html += `<div class="mt-2">`+secHdr('Other context','other_context')
+ (oc.length?oc.map(line).join(''):line('-'))+`</div>`;
$('#ctxPanel').innerHTML = html;
$('#ctxPending')?.classList.toggle('d-none', !p.ai_context_pending);
}
async function editSection(section){
if(!curPerson) return;
const cur = (curPerson.ai_context||{})[section];
// Capture the RAW prompt result and bail on Cancel/Esc BEFORE any transform. Previously the
// other_context branch did `(prompt(...)||'')`, which swallowed the null on Cancel into '' -> [] and
// then PUT an empty array - overwriting AND pinning the section, so cancelling an edit wiped the AI
// context (the "pen-edit" bug). null must abort, not become empty.
const raw = section==='other_context'
? prompt('Other context - one item per line:', (cur||[]).join('\n'))
: prompt('Edit summary:', cur||'');
if(raw===null) return;
const val = section==='other_context'
? raw.split('\n').map(s=>s.trim()).filter(Boolean)
: raw;
await api('/people/'+curPerson.id+'/ai-context',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({section,value:val})});
openPerson(curPerson.id);
}
async function releaseSection(section){ await api('/people/'+curPerson.id+'/ai-context/'+section+'/release',{method:'POST'}); openPerson(curPerson.id); }
// Conflict review: a caller was given contradicting yes/no answers over time. Show both the current and the
// superseded decision(s) with their quotes/dates/source call, and let the operator set the authoritative
// answer (backend appends an operator entry) or dismiss (acknowledge, current stands). grep: conflict-review.
let conflictModal=null;
function openConflict(entryId){
const led=(curPerson&&curPerson.resolutions_ledger)||[];
const cur=led.find(e=>e.id===entryId); if(!cur) return;
const preds=led.filter(e=>e.superseded_by===entryId);
const sides=[...preds,cur].sort((a,b)=>String(a.first_at||a.last_at||'').localeCompare(String(b.first_at||b.last_at||'')));
const decBadge=d=>`<span class="badge ${d==='no'?'bg-danger':d==='yes'?'bg-success':'bg-secondary'}">${esc((d||'').toUpperCase())}</span>`;
const src=e=>e.source==='operator'?'set by operator':(e.last_event_id?('from call #'+esc(e.last_event_id)):'from a call');
const rows=sides.map((e,i)=>`<div class="border rounded p-2 mb-2">
<div class="d-flex align-items-center gap-2 mb-1">${decBadge(e.decision)}
<span class="micro-label">${i===sides.length-1?'Most recent':'Earlier'}</span>
<span class="text-muted small ms-auto">${esc(e.last_at||e.first_at||'')} · ${src(e)}</span></div>
${e.quote?`<div class="fst-italic small">“${esc(e.quote)}”</div>`:'<div class="text-muted small">no quote recorded</div>'}</div>`).join('');
const decisions=[...new Set(sides.map(e=>e.decision))];
const btns=decisions.map(d=>`<button class="btn ${d==='no'?'btn-danger':d==='yes'?'btn-success':'btn-secondary'}"
onclick="resolveConflict('${esc(entryId)}','${esc(d)}')">Correct answer is ${esc((d||'').toUpperCase())}</button>`).join('');
$('#conflictBody').innerHTML=`
<div class="modal-header"><h5 class="modal-title"><i class="fas fa-triangle-exclamation text-warning me-2"></i>Conflicting decisions</h5>
<button type="button" class="btn-close" data-mdb-dismiss="modal"></button></div>
<div class="modal-body">
<div class="fw-bold mb-3">${esc(cur.question||'')}</div>
${rows}
<div class="micro-label mt-3 mb-2">Which answer is correct?</div>
<div class="d-flex gap-2 flex-wrap align-items-center">${btns}
<button class="btn btn-outline-secondary ms-auto" onclick="resolveConflict('${esc(entryId)}',null)">Dismiss (keep current)</button></div>
</div>`;
conflictModal=conflictModal||new mdb.Modal($('#conflict')); conflictModal.show();
}
async function resolveConflict(entryId, decision){
const body=decision?JSON.stringify({decision}):'{}';
await api('/people/'+curPerson.id+'/ledger/'+entryId+'/resolve',{method:'POST',headers:{'Content-Type':'application/json'},body});
if(conflictModal) conflictModal.hide();
openPerson(curPerson.id);
}
// operator records a yes/no by hand -> a real ledger entry (not just dossier prose). Same modal edits an
// existing resolution any time (append-only supersede by id). grep anchors: add-resolution, edit-resolution.
let addResModal=null, arEditId=null;
function showAddRes(){ addResModal=addResModal||new mdb.Modal($('#addRes')); addResModal.show(); setTimeout(()=>$('#arQuestion').focus(),150); }
function openAddResolution(){ if(!curPerson) return;
arEditId=null; $('#arTitle').textContent='Record a resolution'; $('#arQuestion').value=''; showAddRes(); }
function openEditResolution(entryId){ if(!curPerson) return;
const e=(curPerson.resolutions_ledger||[]).find(x=>x.id===entryId); if(!e) return;
arEditId=entryId; $('#arTitle').textContent='Edit resolution'; $('#arQuestion').value=e.question||''; showAddRes(); }
async function submitResolution(decision){
const question=($('#arQuestion').value||'').trim();
if(!question){ toast('Type what was decided first'); $('#arQuestion').focus(); return; }
if(arEditId){
await api('/people/'+curPerson.id+'/ledger/'+arEditId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({question,decision})});
} else {
await api('/people/'+curPerson.id+'/ledger',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question,decision})});
}
if(addResModal) addResModal.hide();
toast((arEditId?'Resolution updated · ':'Resolution recorded · ')+decision.toUpperCase());
arEditId=null;
openPerson(curPerson.id);
}
async function rebuildContext(){ if(!curPerson) return; await api('/people/'+curPerson.id+'/ai-context/rebuild',{method:'POST'}); toast('Context rebuild started…'); pollContext(curPerson.id); }
let ctxPoll=null;
async function pollContext(id){
clearInterval(ctxPoll);
ctxPoll=setInterval(async()=>{
const p = await api('/people/'+id); if(!p){clearInterval(ctxPoll);return;}
if(curPerson && curPerson.id==p.id){ curPerson.ai_context=p.ai_context; curPerson.resolutions_ledger=p.resolutions_ledger;
curPerson.recently_solved=p.recently_solved; curPerson.recently_unsolved=p.recently_unsolved; curPerson.recently_partial=p.recently_partial;
curPerson.ai_context_pending=p.ai_context_pending; curPerson.ai_context_pinned_sections=p.ai_context_pinned_sections;
renderContext(curPerson); }
if(!p.ai_context_pending) clearInterval(ctxPoll);
},3000);
}
/* ======================= BOOT ======================= */
renderOpFilter(); renderStandby();
const params=new URLSearchParams(location.search);
if(params.has('shot')){ pollStatus(); pollDevice(); setView(params.get('view')==='people'?'people':'archive'); }
else {
pollStatus(); pollDevice(); setView('archive');
setInterval(pollPop,1200);
setInterval(()=>{ if(view==='archive') loadArchive(); else if(view==='people') loadPeople(); },4000); // person detail is never auto-refreshed (don't clobber the context editor)
setInterval(pollStatus,30000); setInterval(pollDevice,3000);
}
</script>
</body>
</html>