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