bmc_hub/static/js/remote-mobile.js

340 lines
21 KiB
JavaScript
Raw Normal View History

import {api, bootstrap, element, requestId} from './remote-api.js?v=20261014-day';
const $ = id => document.getElementById(id);
let snapshot, loadedAt, online = false, busy = false, loginStatus = 'connect', kind = 'new', offset = 0, selection;
let blockedUntil = 0, lastCases = 0, polling = false, lastDay = 0;
let uncertain = null, pendingOwner = null;
let contactOffset = 0, pendingCall = null;
const renderedLists = new Map();
function listChanged(key, data) {
const next = JSON.stringify(data);
if (renderedLists.get(key) === next) return false;
renderedLists.set(key, next);
return true;
}
try { pendingCall = JSON.parse(sessionStorage.getItem('hubRemoteCall') || 'null'); } catch (_) {}
try { const stored = JSON.parse(sessionStorage.getItem('hubRemotePending') || 'null'); uncertain = stored?.payload; pendingOwner = stored?.user_id; } catch (_) {}
const notice = text => { $('notice').textContent = text; };
const serverNow = () => snapshot?.server_time ? new Date(snapshot.server_time).getTime() + performance.now() - loadedAt : Date.now();
function buttons() {
document.querySelectorAll('#controls button').forEach(b => { b.disabled = !online || busy || !!uncertain; });
document.querySelectorAll('[data-phone-call]').forEach(b => {b.disabled ||= !!pendingCall;});
$('uncertain').hidden = !uncertain;
$('retry-command').disabled = !online || busy;
$('check-command').disabled = !online || busy;
const active = snapshot?.timer.active.active;
document.querySelector('[data-action="start"]').disabled ||= !snapshot?.active_case || active;
document.querySelector('[data-action="pause"]').disabled ||= !active;
document.querySelector('[data-action="stop"]').disabled ||= !active;
}
function deadline(row) {
if (!row?.deadline) return 'Ingen deadline';
const seconds = Math.floor((new Date(row.deadline) - serverNow()) / 1000);
return `${seconds < 0 ? 'Deadline overskredet' : 'Deadline om ' + Math.ceil(seconds / 60) + ' min'} · ${new Date(row.deadline).toLocaleString('da-DK', {timeZone:'Europe/Copenhagen'})}`;
}
function login(status) {
loginStatus = status; online = false; snapshot = null;
$('login').hidden = false; $('controls').hidden = true;
$('login').scrollIntoView({block:'start', behavior:'instant'});
$('code-label').hidden = status === 'locked'; $('code').required = status !== 'locked';
$('device-label').hidden = status === 'locked';
$('login-title').textContent = status === 'locked' ? 'Lås op med din daglige PIN' : 'Forbind din enhed';
$('login-submit').textContent = status === 'locked' ? 'Lås op' : 'Forbind';
$('connection').textContent = status === 'locked' ? 'Låst' : 'Ikke forbundet';
$('connection').style.setProperty('--connection-dot', '#8da1b6');
buttons();
}
function failure(error) {
online = false; buttons(); notice(error.message);
$('connection').textContent = 'Ingen forbindelse · timeren fortsætter i Hub';
$('connection').style.setProperty('--connection-dot', '#f9c26b');
if (error.status === 423) login('locked');
if (error.status === 401) login('connect');
if (error.retry) blockedUntil = Date.now() + error.retry * 1000;
}
function render() {
$('login').hidden = true; $('controls').hidden = false;
$('connection').textContent = 'Forbundet · ' + snapshot.user_name;
$('connection').style.setProperty('--connection-dot', '#5ee5bc');
$('desktop').textContent = snapshot.desktop_connected ? 'Hub-fane forbundet' : 'Computer ikke forbundet';
$('desktop').classList.toggle('connected', snapshot.desktop_connected);
document.querySelector('.hero').classList.toggle('running', snapshot.timer.active.active);
$('active-case').textContent = snapshot.active_case ? `#${snapshot.active_case.id} · ${snapshot.active_case.titel}` : 'Vælg en sag';
$('deadline').textContent = deadline(snapshot.active_case);
$('deadline').className = snapshot.active_case?.deadline_state || '';
$('timer-case').textContent = snapshot.timer.active.active ? `Timer: #${snapshot.timer.active.sag_id} · ${snapshot.timer.active.sag_navn}` : 'Ingen timer kører';
$('today').textContent = `${Math.floor(snapshot.today_minutes / 60)} t ${snapshot.today_minutes % 60} min`;
$('remote-date').textContent = new Intl.DateTimeFormat('da-DK', {day:'numeric',month:'long',timeZone:'Europe/Copenhagen'}).format(new Date(serverNow()));
const unread = snapshot.messages.list.filter(m => m.is_unread).length;
$('message-count').textContent = unread; $('message-count').hidden = !unread;
document.querySelectorAll('.case').forEach(b => b.classList.toggle('selected', Number(b.dataset.caseId) === snapshot.active_case?.id));
if (listChanged('paused', snapshot.timer.paused)) {
$('paused').replaceChildren();
snapshot.timer.paused.forEach(timer => {
const row = element('div', `${timer.sag_navn} · ${timer.elapsed_hhmmss}`, 'row');
const resume = element('button', 'Genoptag'); resume.onclick = () => send('resume', {time_id: timer.time_entry_id});
resume.disabled = snapshot.timer.active.active;
const stop = element('button', 'Stop og gem'); stop.onclick = () => send('stop', {time_id: timer.time_entry_id});
row.append(resume, stop); $('paused').append(row);
});
}
if (listChanged('messages', snapshot.messages.list)) {
$('messages').replaceChildren();
snapshot.messages.list.forEach(message => {
const row = element('div', '', 'row');
row.append(element('strong', `${message.from}${message.is_unread ? ' · Ulæst' : ''}`), element('p', message.text));
if (message.requires_manual_ack && !message.is_acknowledged && !message.is_own) {
const ack = element('button', 'Bekræft læst'); ack.onclick = () => send('ack', {message_id: message.id}); row.append(ack);
}
$('messages').append(row);
});
if (!snapshot.messages.list.length) $('messages').textContent = 'Ingen beskeder';
}
buttons();
document.querySelectorAll('#paused button:first-of-type').forEach(b => { b.disabled ||= snapshot.timer.active.active; });
}
async function sync() {
if (polling || busy || document.hidden || loginStatus !== 'ready') return;
polling = true;
try { const reconnecting = !online; const next = await api('/state');
if (!next?.timer?.active || !Array.isArray(next.timer.paused) || !Array.isArray(next.messages?.list)) throw new Error('Hub sendte et ufuldstændigt svar. Prøver igen…');
snapshot = next; loadedAt = performance.now(); online = true;
if (reconnecting && !uncertain) notice('');
if (uncertain && pendingOwner !== snapshot.user_id) { clearPending(); notice('Den tidligere handling tilhørte en anden bruger og er fjernet fra denne enhed.'); }
render();
if (Date.now() - lastDay > 30000) await syncDay();
await syncPhone();
if (Date.now() - lastCases > 15000) await loadCases();
} catch (error) { failure(error); } finally { polling = false; }
}
function clearPending() { uncertain = null; pendingOwner = null; sessionStorage.removeItem('hubRemotePending'); buttons(); }
async function send(action, extra = {}, retry = null) {
if (busy || !online || !snapshot || (uncertain && !retry)) return;
const data = retry || {request_id: requestId(), version: snapshot.version, timer_version: snapshot.timer_version,
action, case_id: snapshot.active_case?.id, description: $('description').value.trim(), ...extra};
if (['stop', 'manual'].includes(action) && !data.description) { notice('Angiv beskrivelse først'); $('description').focus(); return; }
busy = true; buttons(); notice('');
// Preserve the same idempotency key through an uncertain network response and reload.
// Nothing is automatically replayed after losing connection.
pendingOwner = snapshot.user_id;
sessionStorage.setItem('hubRemotePending', JSON.stringify({user_id: pendingOwner, payload: data}));
try { await api('/command', data); clearPending(); if (action === 'manual' || action === 'stop') $('description').value = ''; }
catch (error) {
notice(error.message);
if (!error.status || error.status >= 500) uncertain = data;
else clearPending();
if (error.status !== 409) failure(error);
}
finally { busy = false; await sync(); buttons(); }
}
$('retry-command').onclick = () => send(uncertain.action, {}, uncertain);
$('check-command').onclick = async () => {
if (!uncertain || busy) return;
try {
const result = await api('/command-result/' + uncertain.request_id);
if (result.completed) { clearPending(); notice('Handlingen blev gemt i Hub.'); await sync(); }
else notice('Hub har ikke registreret handlingen. Brug Prøv samme handling igen.');
} catch (error) { failure(error); }
};
async function select(row) {
if (snapshot?.timer.active.active && snapshot.timer.active.sag_id !== row.id) {
selection = row; $('switch-dialog').showModal();
} else await send('select', {case_id: row.id});
}
document.querySelectorAll('[data-switch]').forEach(b => b.onclick = async () => {
$('switch-dialog').close();
if (b.dataset.switch !== 'cancel') await send('select', {case_id: selection.id, switch_timer: b.dataset.switch === 'timer'});
});
async function loadCases(append = false) {
const data = await api(`/cases?kind=${kind}&q=${encodeURIComponent($('search').value)}&offset=${offset}`);
if (!append && !listChanged('cases', {kind, offset, query:$('search').value, data})) {lastCases=Date.now();buttons();return;}
if (append) renderedLists.delete('cases');
if (!append) $('cases').replaceChildren();
data.items.forEach(row => {
const button = element('button', `#${row.id} · ${row.titel}`, 'case');
button.dataset.caseId = row.id;
button.classList.toggle('selected', row.id === snapshot?.active_case?.id);
button.append(element('small', `${row.customer_name || ''} · ${row.status} · ${deadline(row)}`, row.deadline_state));
button.onclick = () => select(row); $('cases').append(button);
});
if (!data.items.length && !append) $('cases').textContent = 'Ingen sager';
$('more').hidden = !data.has_more; lastCases = Date.now(); buttons();
}
document.querySelectorAll('[data-action]').forEach(b => b.onclick = () => send(b.dataset.action));
document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => {
kind = b.dataset.kind; offset = 0;
document.querySelectorAll('[data-kind]').forEach(x => x.setAttribute('aria-pressed', x === b));
loadCases().catch(failure);
});
$('search-form').onsubmit = event => { event.preventDefault(); kind = 'search'; offset = 0; loadCases().catch(failure); };
$('more').onclick = () => { offset += 50; loadCases(true).catch(failure); };
$('manual-form').onsubmit = event => { event.preventDefault(); send('manual', {minutes: Number($('minutes').value)}); };
$('login-form').onsubmit = async event => {
event.preventDefault(); if (Date.now() < blockedUntil || busy) return;
busy = true; $('login-submit').disabled = true;
try { await api(loginStatus === 'locked' ? '/unlock' : '/connect', {code: $('code').value, pin: $('pin').value, name: $('device').value || 'Mobil / tablet'});
loginStatus = 'ready'; $('pin').value = ''; $('code').value = ''; notice('');
} catch (error) { notice(error.message); if (error.retry) blockedUntil = Date.now() + error.retry * 1000; }
finally { busy = false; await sync(); }
};
$('logout').onclick = async () => { try { await api('/logout', {}); login('connect'); } catch (error) { failure(error); } };
setInterval(() => {
const seconds = Math.max(0, Math.ceil((blockedUntil - Date.now()) / 1000));
$('login-submit').disabled = busy || seconds > 0;
if (seconds) $('login-submit').textContent = `Prøv igen om ${seconds} sek`;
else $('login-submit').textContent = loginStatus === 'locked' ? 'Lås op' : 'Forbind';
if (snapshot) {
const active = snapshot.timer.active;
const elapsed = active.active ? active.elapsed + Math.floor((performance.now() - loadedAt) / 1000) : 0;
$('elapsed').textContent = [Math.floor(elapsed/3600), Math.floor(elapsed/60)%60, elapsed%60].map(x => String(x).padStart(2,'0')).join(':');
$('deadline').textContent = deadline(snapshot.active_case);
const left = snapshot.active_case?.deadline ? (new Date(snapshot.active_case.deadline) - serverNow()) / 1000 : null;
$('deadline').className = left === null ? '' : left < 0 ? 'overdue' : left <= 3600 ? 'warning' : '';
}
}, 1000);
setInterval(sync, 3000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) sync(); });
window.addEventListener('online', sync);
window.addEventListener('pageshow', () => sync());
window.addEventListener('offline', () => failure(new Error('Forbindelsen er afbrudt')));
try { const result = await bootstrap(); loginStatus = result.status; if (loginStatus === 'ready') await sync(); else login(loginStatus); }
catch (error) { failure(error); }
async function syncDay() {
try {
const data = await api('/day');
if (!Array.isArray(data.items)) throw new Error('Dagens plan kunne ikke læses');
if (listChanged('day', data)) {
const host = $('day-items');host.replaceChildren();
data.items.forEach(item => {
const row=element('div','','day-plan-item');
const time=value=>new Intl.DateTimeFormat('da-DK',{hour:'2-digit',minute:'2-digit',timeZone:'Europe/Copenhagen'}).format(new Date(value));
const detail=element('div','');
detail.append(element('strong',item.title||'Planlagt opgave'),element('small',item.customer_name||'Intern opgave'));
if (item.case_id) {
const button=element('button',`Vælg sag #${item.case_id}`);
button.onclick=()=>select({id:item.case_id});detail.append(button);
}
row.append(element('span',`${time(item.starts_at)}–${time(item.ends_at)}`,'day-plan-time'),detail);host.append(row);
});
if(!data.items.length)host.textContent='Ingen planlagte opgaver i dag';
}
$('day-status').textContent='';lastDay=Date.now();
} catch(error) {
$('day-status').textContent='Dagens plan kunne ikke opdateres. Prøver igen…';
if(error.status===401||error.status===423)throw error;
}
}
let wakeLock=null,keepAwake=false;
async function holdScreen() {
if(!keepAwake||document.hidden||wakeLock)return;
try {
if(!navigator.wakeLock)throw new Error('unsupported');
wakeLock=await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release',()=>{wakeLock=null;$('screen-awake-status').textContent='Skærmlåsen blev frigivet';});
$('screen-awake-status').textContent='Skærmen holdes tændt, mens remote er synlig';
} catch(_) {$('screen-awake-status').textContent='Browseren kunne ikke holde skærmen tændt';}
}
$('screen-awake').onclick=async()=>{
keepAwake=!keepAwake;$('screen-awake').textContent=keepAwake?'Tillad skærmen at slukke':'Hold skærmen tændt';
if(keepAwake)await holdScreen();else {await wakeLock?.release();wakeLock=null;$('screen-awake-status').textContent='';}
};
document.addEventListener('visibilitychange',()=>{if(!document.hidden){lastDay=0;holdScreen();}});
async function syncPhone() {
try {
const data = await api('/telephony');
$('phone-card').hidden = !data.allowed;
document.querySelector('.remote-nav a[href="#phone-card"]').hidden = !data.allowed;
$('call-count').textContent = data.incoming?.length || 0; $('call-count').hidden = !data.incoming?.length;
if (listChanged('incoming', data.incoming || [])) {
$('incoming-calls').replaceChildren();
(data.incoming || []).forEach(call => {
const row = element('div', '', 'incoming-call');
row.append(element('strong', call.contact_name || call.caller_number || 'Ukendt nummer'),
element('p', [call.company_name, call.caller_number, (call.ringing_phones || []).join(', ')].filter(Boolean).join(' · ')));
$('incoming-calls').append(row);
});
if (!data.incoming?.length) $('incoming-calls').textContent = 'Ingen indgående opkald';
}
if (pendingCall && pendingCall.user_id !== snapshot.user_id) {
pendingCall = null; sessionStorage.removeItem('hubRemoteCall');
}
$('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall;
if (pendingCall) $('phone-notice').textContent = 'Svaret på opkaldet mangler. Kontrollér status og din telefon før et nyt opkald.';
} catch (error) {
renderedLists.delete('incoming');
$('incoming-calls').textContent = 'Opkaldsvisningen er midlertidigt utilgængelig';
if (error.status === 401 || error.status === 423) throw error;
}
buttons();
}
async function loadContacts(append = false) {
const data = await api(`/contacts?q=${encodeURIComponent($('contact-search').value)}&offset=${contactOffset}`);
if (!append) $('contact-results').replaceChildren();
data.items.forEach(contact => {
const row = element('div', '', 'row');
row.append(element('strong', [contact.first_name, contact.last_name].filter(Boolean).join(' ')),
element('p', [...(contact.company_names || []), contact.email].filter(Boolean).join(' · ')));
['phone', 'mobile'].forEach(field => {
if (!contact[field]) return;
const button = element('button', `Ring ${field === 'mobile' ? 'mobil' : 'telefon'} · ${contact[field]}`);
button.dataset.phoneCall = 'yes';
button.onclick = () => dialContact(contact.id, field);
row.append(button);
});
$('contact-results').append(row);
});
if (!data.items.length && !append) $('contact-results').textContent = 'Ingen kontakter fundet';
$('contact-more').hidden = !data.has_more; buttons();
}
async function dialContact(contact_id, field) {
if (!online || busy || pendingCall) return;
busy = true; buttons();
const payload = {request_id: requestId(), contact_id, field};
pendingCall = {user_id: snapshot.user_id, payload};
sessionStorage.setItem('hubRemoteCall', JSON.stringify(pendingCall));
try {
const result = await api('/call', payload);
$('phone-notice').textContent = result.message;
if (result.status !== 'uncertain') { pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); }
} catch (error) {
$('phone-notice').textContent = error.status ? error.message : 'Svaret mangler. Kontrollér telefonen før et nyt opkald.';
if (error.status) { pendingCall = null; sessionStorage.removeItem('hubRemoteCall'); }
} finally { busy = false; $('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall; buttons(); }
}
$('contact-search-form').onsubmit = event => {event.preventDefault(); contactOffset = 0; loadContacts().catch(error => {$('phone-notice').textContent = error.message;});};
$('contact-more').onclick = () => {contactOffset += 50; loadContacts(true).catch(error => {$('phone-notice').textContent = error.message;});};
$('check-call').onclick = async () => {
if (!pendingCall || !online || busy) return;
busy = true; buttons();
try {
// Read status only; never submit an old call while reconnecting.
const result = await api(`/call/${pendingCall.payload.request_id}`);
$('phone-notice').textContent = result.message;
if (result.status !== 'uncertain') {pendingCall = null; sessionStorage.removeItem('hubRemoteCall');}
} catch (error) {$('phone-notice').textContent = error.message;}
finally {busy = false; $('check-call').hidden = !pendingCall; $('clear-call').hidden = !pendingCall; buttons();}
};
$('clear-call').onclick = () => {
pendingCall = null; sessionStorage.removeItem('hubRemoteCall');
$('check-call').hidden = true; $('clear-call').hidden = true;
$('phone-notice').textContent = 'Du kan nu starte et nyt opkald'; buttons();
};
const navLinks = [...document.querySelectorAll('.remote-nav a')];
function highlightNavigation(id) {
navLinks.forEach(link => {
if (link.hash === '#' + id) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
}
navLinks.forEach(link => link.addEventListener('click', () => highlightNavigation(link.hash.slice(1))));
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(entries => {
const visible = entries.filter(entry => entry.isIntersecting && !entry.target.hidden);
if (visible.length) highlightNavigation(visible[0].target.id);
}, {rootMargin:'-15% 0px -60% 0px',threshold:0});
navLinks.forEach(link => observer.observe(document.querySelector(link.hash)));
}