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.
819 lines
60 KiB
HTML
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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&¤t.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 & 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 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 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 & outcome save automatically.</span>
|
|
<button class="btn btn-success btn-lg" onclick="closeWrap()"><i class="fas fa-check me-2"></i>File & 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&¤t._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 & 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>
|