bmc_hub/static/js/remote-mobile.js
Christian 85e019783b Add comprehensive test coverage for case creation, assignment, and remote recovery
- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads.
- Added tests for v5 assignment functionality, validating employee identification and group assignment.
- Implemented tests for remote recovery scenarios, including handling network failures and response validation.
- Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation.
- Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions.
- Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
2026-10-15 12:17:09 +02:00

340 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)));
}