45 lines
20 KiB
JavaScript
45 lines
20 KiB
JavaScript
|
|
(() => {
|
|||
|
|
const root=document.querySelector('.planner-page'); if(!root)return;
|
|||
|
|
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0};
|
|||
|
|
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
|
|||
|
|
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|||
|
|
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
|||
|
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|||
|
|
const fmtMin=m=>m?`${Math.floor(m/60)}t${m%60?' '+m%60+'m':''}`:'0t';
|
|||
|
|
const api=async(url,opt={})=>{const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opt});let b;try{b=await r.json()}catch{b={detail:'Ukendt serverfejl'}}if(!r.ok){const e=new Error(typeof b.detail==='string'?b.detail:b.detail?.message||'Handlingen mislykkedes');e.status=r.status;e.body=b;throw e}return b};
|
|||
|
|
function range(){let a=new Date(state.anchor),b;if(state.view==='day'){a.setHours(0,0,0,0);b=new Date(a);b.setDate(b.getDate()+1)}else if(state.view==='week'){a.setHours(0,0,0,0);a.setDate(a.getDate()-((a.getDay()+6)%7));b=new Date(a);b.setDate(b.getDate()+7)}else{a=new Date(a.getFullYear(),a.getMonth(),1);b=new Date(a.getFullYear(),a.getMonth()+1,1)}return[a,b]}
|
|||
|
|
function selectedUsers(){return state.scope==='mine'?[Number(root.dataset.currentUser)]:state.meta.users.map(u=>u.id)}
|
|||
|
|
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
|
|||
|
|
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
|
|||
|
|
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
|
|||
|
|
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===current?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
|||
|
|
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
|
|||
|
|
function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`<article class="case-card" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div><div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
|
|||
|
|
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
|
|||
|
|
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
|
|||
|
|
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
|||
|
|
function render(a,b){const opts={day:'numeric',month:'short'};$('#periodLabel').textContent=state.view==='month'?a.toLocaleDateString('da-DK',{month:'long',year:'numeric'}):`${a.toLocaleDateString('da-DK',opts)} – ${new Date(b-1).toLocaleDateString('da-DK',{...opts,year:'numeric'})}`;state.view==='month'?renderMonth(a):renderTimeline(a,b)}
|
|||
|
|
function cap(uid,key){return state.data.capacity.find(c=>c.user_id===uid&&c.date===key)||{state:'gray',label:'Ingen arbejdstid'}}
|
|||
|
|
function canEdit(uid){return (state.meta.editable_user_ids||[]).includes(Number(uid))}
|
|||
|
|
function integrationWarning(){return (state.data.integration_statuses||[]).some(x=>x.status==='degraded')?'<div class="alert alert-warning m-2 py-2"><i class="bi bi-cloud-slash me-1"></i>Ekstern travl tid kan mangle. Intern planlægning virker fortsat.</div>':''}
|
|||
|
|
function blockStyle(start,end,lane={index:0,count:1}){const first=7*60,last=18*60,total=last-first,s=new Date(start),e=new Date(end),sm=s.getHours()*60+s.getMinutes(),em=e.getHours()*60+e.getMinutes(),top=Math.max(0,(sm-first)/total*660),height=Math.max(24,(Math.min(last,em)-Math.max(first,sm))/total*660),left=lane.index*(100/lane.count),leftPx=26-lane.index*(29/lane.count),width=100/lane.count,widthPx=29/lane.count;return `top:${top}px;height:${height}px;left:calc(${left}% + ${leftPx}px);right:auto;width:calc(${width}% - ${widthPx}px)`}
|
|||
|
|
function overlapLayout(planned,registered){const all=[...planned.map(item=>({key:`p:${item.id}`,item})),...registered.map(item=>({key:`r:${item.id}`,item}))].sort((a,b)=>new Date(a.item.starts_at)-new Date(b.item.starts_at));const result=new Map();let group=[],groupEnd=0;const flush=()=>{if(!group.length)return;const laneEnds=[];for(const event of group){const start=+new Date(event.item.starts_at);let lane=laneEnds.findIndex(end=>end<=start);if(lane<0)lane=laneEnds.length;laneEnds[lane]=+new Date(event.item.ends_at);event.lane=lane}for(const event of group)result.set(event.key,{index:event.lane,count:laneEnds.length});group=[];groupEnd=0};for(const event of all){const start=+new Date(event.item.starts_at),end=+new Date(event.item.ends_at);if(group.length&&start>=groupEnd)flush();group.push(event);groupEnd=Math.max(groupEnd,end)}flush();return result}
|
|||
|
|
function hourMarks(){return Array.from({length:12},(_,i)=>`<span class="hour-mark" style="top:${i*60}px">${pad(i+7)}:00</span>`).join('')}
|
|||
|
|
function registeredHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="registered-time" style="${blockStyle(x.starts_at,x.ends_at,lane)}"><div class="time"><i class="bi bi-stopwatch me-1"></i>${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())} · registreret</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${fmtMin(x.minutes)} brugt</div></div>`}
|
|||
|
|
function untimedHtml(items){return items.length?`<div class="untimed-registers">${items.map(x=>`<div class="untimed-register" title="${esc(x.case_title)}"><i class="bi bi-stopwatch me-1"></i>Uden tidspunkt · #${x.sag_id} · ${fmtMin(x.minutes)} registreret</div>`).join('')}</div>`:''}
|
|||
|
|
function renderTimeline(a,b){const users=state.meta.users.filter(u=>selectedUsers().includes(u.id)),days=[];for(let d=new Date(a);d<b;d.setDate(d.getDate()+1))days.push(new Date(d));const cols=`150px repeat(${days.length},minmax(130px,1fr))`;let html=integrationWarning()+`<div class="week-grid" style="grid-template-columns:${cols}"><div class="grid-cell grid-head employee-head">Medarbejder</div>${days.map(d=>`<div class="grid-cell grid-head">${d.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'})}</div>`).join('')}`;for(const u of users){html+=`<div class="grid-cell employee-head">${esc(u.name)}<div class="small text-muted mt-2">Arbejdsdag 07–18</div></div>`;for(const d of days){const key=dateKey(d),c=cap(u.id,key),items=state.data.allocations.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),allRegistered=(state.data.time_entries||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),registered=allRegistered.filter(x=>x.has_time),untimed=allRegistered.filter(x=>!x.has_time),busy=state.data.external_busy.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),lanes=overlapLayout(items,registered);html+=`<div class="grid-cell drop-cell" data-user="${u.id}" data-date="${key}">${hourMarks()}<span class="capacity-pill"><i class="dot ${c.state}"></i>${esc(c.label)} · ${esc(c.registered_label||'0 timer registreret')}</span>${busy.map(x=>`<div class="external-busy" style="${blockStyle(x.starts_at,x.ends_at)}">Ekstern travl tid</div>`).join('')}${registered.map(x=>registeredHtml(x,lanes.get(`r:${x.id}`))).join('')}${items.map(x=>allocationHtml(x,lanes.get(`p:${x.id}`))).join('')}${untimedHtml(untimed)}</div>`}}html+='</div>';$('#timeline').innerHTML=html;bindCalendar()}
|
|||
|
|
function allocationHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="allocation ${x.conflict_status!=='none'?'conflict':''} ${x.locked?'locked':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}" draggable="${x.locked?'false':'true'}" data-allocation="${x.id}"><div class="time">${x.locked?'<i class="bi bi-lock-fill me-1"></i>':''}${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())}</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${esc(x.customer_name)}</div></div>`}
|
|||
|
|
function renderMonth(a){const start=new Date(a);start.setDate(1-((start.getDay()+6)%7));let html=integrationWarning()+'<div class="month-grid">'+['Man','Tir','Ons','Tor','Fre','Lør','Søn'].map(x=>`<div class="grid-cell grid-head">${x}</div>`).join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(d.getDate()+i);const key=dateKey(d),caps=selectedUsers().map(uid=>cap(uid,key)),alloc=state.data.allocations.filter(x=>dateKey(new Date(x.starts_at))===key),planned=caps.reduce((n,c)=>n+c.allocated_minutes,0),registered=caps.reduce((n,c)=>n+(c.registered_minutes||0),0),available=caps.reduce((n,c)=>n+c.available_minutes,0);html+=`<div class="month-day ${d.getMonth()!==a.getMonth()?'other':''}" data-date="${key}"><span class="day-num">${d.getDate()}</span><div class="month-cap">${caps.map(c=>`<span title="${esc(c.label)}"><i class="dot ${c.state}"></i></span>`).join('')} ${fmtMin(planned)} af ${fmtMin(available)} planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${fmtMin(registered)} registreret</span></div>${alloc.slice(0,3).map(x=>`<div class="allocation" data-allocation="${x.id}">#${x.sag_id} ${esc(x.case_title)}</div>`).join('')}</div>`}html+='</div>';$('#timeline').innerHTML=html;document.querySelectorAll('.month-day').forEach(el=>el.onclick=e=>{if(e.target.closest('.allocation'))return;state.anchor=new Date(el.dataset.date+'T12:00');state.view='day';document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view==='day'));loadPeriod()});bindCalendar()}
|
|||
|
|
function bindCalendar(){document.querySelectorAll('.drop-cell').forEach(el=>{if(canEdit(el.dataset.user)){el.ondragover=e=>e.preventDefault();el.ondrop=e=>drop(e,el)}});document.querySelectorAll('[data-allocation]').forEach(el=>{const item=state.data.allocations.find(x=>x.id===Number(el.dataset.allocation));el.onclick=()=>openAllocation(Number(el.dataset.allocation));el.draggable=Boolean(item&&canEdit(item.user_id)&&!item.locked);el.ondragstart=e=>{if(item&&canEdit(item.user_id)&&!item.locked)e.dataTransfer.setData('application/x-allocation',el.dataset.allocation)}})}
|
|||
|
|
async function drop(e,cell){e.preventDefault();const caseId=e.dataTransfer.getData('application/x-case'),allocId=e.dataTransfer.getData('application/x-allocation');const rect=cell.getBoundingClientRect(),ratio=Math.max(0,Math.min(1,(e.clientY-rect.top)/rect.height)),minutes=Math.round((7*60+ratio*11*60)/15)*15;const start=new Date(cell.dataset.date+'T00:00');start.setMinutes(minutes);if(caseId){const c=state.cases.find(x=>x.id===Number(caseId)),defaultMins=Math.max(15,Number(state.meta.settings.default_allocation_minutes)||60),remainingMins=Math.max(15,Number(c?.remaining_minutes)||defaultMins),mins=Math.min(remainingMins,defaultMins);await mutate('/api/v1/planner/allocations','POST',{sag_id:Number(caseId),user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+mins*60000).toISOString(),source:'manual'})}else if(allocId){const x=state.data.allocations.find(a=>a.id===Number(allocId)),dur=new Date(x.ends_at)-new Date(x.starts_at);await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+dur).toISOString(),version:x.version})}}
|
|||
|
|
function openAllocation(id){const x=state.data.allocations.find(a=>a.id===id);if(!x)return;state.selected=x;const editable=canEdit(x.user_id);$('#saveAllocation').classList.toggle('d-none',!editable);$('#deleteAllocation').classList.toggle('d-none',!editable||x.locked);$('#copyAllocation').classList.toggle('d-none',!editable);$('#dialogTitle').textContent=`#${x.sag_id} · ${x.case_title}`;$('#dialogCustomer').textContent=`${x.customer_name} · ${x.user_name}`;$('#dialogStart').value=localISO(new Date(x.starts_at));$('#dialogEnd').value=localISO(new Date(x.ends_at));$('#dialogNote').value=x.note||'';$('#dialogLocked').checked=Boolean(x.locked);$('#dialogStart').disabled=!editable;$('#dialogEnd').disabled=!editable;$('#dialogNote').disabled=!editable;$('#dialogLocked').disabled=!editable;$('#dialogCaseLink').href=`/sag/${x.sag_id}/v3`;$('#dialogWarning').classList.add('d-none');$('#allocationDialog').showModal()}
|
|||
|
|
async function saveSelected(){const x=state.selected;await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{starts_at:new Date($('#dialogStart').value).toISOString(),ends_at:new Date($('#dialogEnd').value).toISOString(),note:$('#dialogNote').value,locked:$('#dialogLocked').checked,version:x.version},true)}
|
|||
|
|
async function deleteSelected(){const x=state.selected;if(!confirm('Slet denne tidsblok? Sagen bevares.'))return;try{const result=await api(`/api/v1/planner/allocations/${x.id}?version=${x.version}`,{method:'DELETE'});$('#allocationDialog').close();const a=result.allocation;toast('Tidsblokken blev slettet','Fortryd',()=>undoMutation(a.id,a.version));await refresh()}catch(e){toast(e.message)}}
|
|||
|
|
async function copySelected(){const x=state.selected,start=new Date(x.starts_at);start.setDate(start.getDate()+1);await mutate(`/api/v1/planner/allocations/${x.id}/copy?starts_at=${encodeURIComponent(start.toISOString())}`,'POST',null,true)}
|
|||
|
|
async function mutate(url,method,body,close=false){try{const result=await api(url,{method,body:body?JSON.stringify(body):undefined});if(close)$('#allocationDialog').close();toast('Planen er gemt','Fortryd',result?.id&&result?.version?()=>undoMutation(result.id,result.version):null);await refresh()}catch(e){if(e.status===409&&e.body?.detail?.conflict_status&&confirm(`${e.message}. Vil du gemme alligevel?`)){body.confirm_conflicts=true;return mutate(url,method,body,close)}toast(e.message);await loadPeriod()}}
|
|||
|
|
async function undoMutation(id,version){try{await api(`/api/v1/planner/allocations/${id}/undo?version=${version}`,{method:'POST'});toast('Ændringen blev fortrudt');await refresh()}catch(e){toast(e.message);await loadPeriod()}}
|
|||
|
|
async function refresh(){await Promise.all([loadCases(),loadPeriod()])}
|
|||
|
|
function toast(message,action,fn){const el=document.createElement('div');el.className='planner-toast';el.textContent=message;if(action&&fn){const b=document.createElement('button');b.textContent=action;b.onclick=()=>{fn();el.remove()};el.append(b)}$('#toastStack').append(el);setTimeout(()=>el.remove(),6000)}
|
|||
|
|
init().catch(e=>{$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`});
|
|||
|
|
})();
|