bmc_hub/static/js/planner.js

45 lines
20 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.

(() => {
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 0718</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>`});
})();