${query?'Ingen sager matcher din søgning':'Alle åbne sager er fordelt'}
${query?'Prøv et andet sagsnummer, navn eller kundenavn.':'Sager uden en medarbejder vises her, når de bliver oprettet.'}
`;
byId('unassignedPage').textContent=data.items.length?`Viser ${offset+1}–${offset+data.items.length}${data.has_more?' · flere sager på næste side':''}`:'';
byId('unassignedPrev').disabled=offset===0;byId('unassignedNext').disabled=!data.has_more;
byId('unassignedStatus').textContent='';
} catch(err) {
if(request!==unassignedRequest)return;
byId('unassignedError').textContent=err.message;byId('unassignedError').hidden=false;
byId('unassignedStatus').textContent='Listen kunne ikke opdateres. Prøv igen.';
} finally {if(request===unassignedRequest)byId('unassignedList').setAttribute('aria-busy','false');}
}
function assignUnassigned(row) {
const employees=metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id));
const dialog=document.createElement('dialog');dialog.setAttribute('aria-labelledby','assignCaseHeading');root.append(dialog);
dialog.innerHTML=``;
const form=dialog.querySelector('form'),save=form.querySelector('[type=submit]'),cancel=form.querySelector('[data-close]');
const close=()=>{dialog.close();dialog.remove();byId('unassignedSearch').focus();};cancel.onclick=close;
dialog.addEventListener('close',()=>dialog.remove());
form.onsubmit=async event=>{
event.preventDefault();if(save.disabled)return;
const employee=Number(form.elements.employee.value),value=form.elements.minutes.value;
if(!employee)return;
save.disabled=true;cancel.disabled=true;save.textContent='Tilføjer…';form.querySelector('[role=alert]').textContent='';
try {
const allocation=await api(`/cases/${row.id}/assignees`,{method:'POST',body:JSON.stringify({user_id:employee,estimated_minutes:value===''?null:Number(value)})});
const name=employees.find(u=>u.id===employee)?.name||'medarbejderen';close();await loadUnassigned(unassignedOffset);
byId('unassignedStatus').textContent=allocation.created?`Sag #${row.id} er nu i køen hos ${name}.`:`Sag #${row.id} er allerede i køen hos ${name}. Den forventede arbejdstid er uændret.`;
const link=document.createElement('a');link.className='ms-2';link.textContent='Åbn medarbejderens kø';
link.href='/planning?view=queue&user_id='+employee;byId('unassignedStatus').append(link);
await loadQueue();calendar.refetchEvents();
} catch(err) {form.querySelector('[role=alert]').textContent=err.message;save.disabled=false;cancel.disabled=false;save.textContent='Tilføj til kø';}
};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();form.elements.employee.focus();
}
byId('unassignedList').onclick=event=>{const dates=event.target.closest('[data-task-dates]');if(dates)return editTaskDates(Number(dates.dataset.taskDates)).catch(error);const button=event.target.closest('[data-assign-case]');const row=unassignedRows.find(r=>r.id===Number(button?.dataset.assignCase));if(row)assignUnassigned(row);};
byId('unassignedSearch').oninput=()=>{clearTimeout(unassignedSearchTimer);unassignedSearchTimer=setTimeout(()=>loadUnassigned(),250);};
byId('unassignedRefresh').onclick=()=>loadUnassigned(unassignedOffset);
byId('unassignedPrev').onclick=()=>loadUnassigned(Math.max(0,unassignedOffset-25));
byId('unassignedNext').onclick=()=>loadUnassigned(unassignedOffset+25);
root.addEventListener('click',event=>root.querySelectorAll('.work-help[open]').forEach(details=>{if(!details.contains(event.target))details.open=false;}));
let taskDrag=null,taskSource='inbox',currentView='work',taskBusy=false;
byId('workSourcePane').append(byId('workUnassigned'));
const caseDate=value=>value?new Intl.DateTimeFormat('en-CA',{timeZone:'Europe/Copenhagen',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date(value)):'';
const canEditDates=()=>metadata.permissions.some(p=>['cases.edit','tickets.edit'].includes(p));
function taskFromElement(element) {
const id=Number(element?.dataset.taskId);if(!id)return null;
const source=element.dataset.taskSource;
const row=(source==='inbox'?unassignedRows:queue.items).find(r=>(r.sag_id||r.id)===id);
return row?{id,title:row.titel,updated_at:row.case_updated_at,source:source==='inbox'?null:target,version:queue.version}:null;
}
function taskMessage(message) {byId('workTaskStatus').textContent=message;byId('workError').hidden=true;}
async function refreshTasks() {await Promise.all([loadQueue(),currentView==='tasks'?loadUnassigned(unassignedOffset):Promise.resolve()]);calendar.refetchEvents();}
async function transferWithBookings(id,body) {
const path=`/cases/${id}/transfer`;
try {await api(path,{method:'POST',body:JSON.stringify(body)});return true;}
catch(err) {
if(err.detail?.code!=='future_bookings_choice')throw err;
const detail=err.detail,employee=metadata.users.find(u=>u.id===body.target_user_id)?.name||'den nye medarbejder';
const action=await new Promise(resolve=>{
const dialog=document.createElement('dialog');root.append(dialog);
dialog.innerHTML=`
Overdrag sag #${id}
Sagen har ${detail.booking_count} kommende reservationer. Hvad skal der ske med dem?
Tidspunkterne bevares i den nye medarbejders kalender.
Den tidligere medarbejders tider frigives. Andre deltagere og registreret tid bevares.
`;
const finish=action=>{dialog.close();dialog.remove();resolve(action);};
dialog.querySelectorAll('[data-action]').forEach(button=>button.onclick=()=>finish(button.dataset.action));
dialog.oncancel=e=>{e.preventDefault();finish(null);};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
});
if(!action||action==='cancel')return false;
await saveWithConflict(path,{method:'POST',body:JSON.stringify({...body,future_bookings_action:action,future_bookings:detail.bookings})});return true;
}
}
async function assignTask(task,employee) {
if(taskBusy)return;taskBusy=true;
try {
if(task.source===employee){await showTaskSource('queue');return;}
if(task.source){
const destination=await api('/queue?user_id='+employee+'&limit=1');
if(!await transferWithBookings(task.id,{source_user_id:task.source,target_user_id:employee,source_version:task.version,target_version:destination.version}))return;
} else await api(`/cases/${task.id}/assignees`,{method:'POST',body:JSON.stringify({user_id:employee})});
await refreshTasks();taskMessage(`Sag #${task.id} er nu hos ${metadata.users.find(u=>u.id===employee)?.name||'medarbejderen'}. Den ansvarlige er uændret.`);
} catch(err){error(err);await refreshTasks();}finally{taskBusy=false;}
}
async function unassignTask(task) {
if(!task.source||taskBusy)return;taskBusy=true;
try {await api(`/cases/${task.id}/assignees/${task.source}?version=${task.version}`,{method:'DELETE'});await refreshTasks();taskMessage(`Sag #${task.id} er fjernet fra medarbejderens kø. Andre tildelinger og ansvarlig er bevaret.`);}
catch(err){error(err);}finally{taskBusy=false;}
}
async function setTaskDate(task,day,kind,instant=null) {
if(!canEditDates())throw new Error('Du har ikke rettighed til at ændre sagens datoer.');
if(!task.updated_at)throw new Error('Opgavelisten skal genindlæses. Genindlæs siden, og træk opgaven igen.');
if(!/^\d{4}-\d{2}-\d{2}$/.test(day))throw new Error('Vælg en dato i kalenderen.');
const body={updated_at:task.updated_at};body[kind==='deadline'?(instant?'deadline':'deadline_date'):'start_date']=kind==='deadline'&&instant?instant:day;if(kind==='start'&&instant)body.start_time=instant.slice(11,19);
await api(`/cases/${task.id}/planning`,{method:'PATCH',body:JSON.stringify(body)});
await refreshTasks();taskMessage(`${kind==='deadline'?'Deadline':'Startdato'} for sag #${task.id} er sat til ${day.split('-').reverse().join('.')}.`);
}
async function editTaskDates(id,kind=null) {
const plan=await api(`/cases/${id}/planning`),dialog=document.createElement('dialog');root.append(dialog);
dialog.innerHTML=``;
dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();};
dialog.querySelector('form').onsubmit=async event=>{event.preventDefault();const form=event.target,button=form.querySelector('.btn-primary');button.disabled=true;
const body={updated_at:plan.updated_at};if(kind!=='deadline'){body.start_date=form.elements.start.value||null;if(!body.start_date)body.start_time=null;}if(kind!=='start')body.deadline_date=form.elements.deadline.value||null;
try{await api(`/cases/${id}/planning`,{method:'PATCH',body:JSON.stringify(body)});dialog.close();dialog.remove();await refreshTasks();taskMessage(`Datoerne for sag #${id} er gemt.`);}catch(err){form.querySelector('[role=alert]').textContent=err.message;button.disabled=false;}};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
}
function planTaskSlot(task,day=caseDate(new Date()),clock='09:00') {
const employees=metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id));
const dialog=document.createElement('dialog');dialog.setAttribute('aria-labelledby','slotHeading');dialog.addEventListener('close',()=>dialog.remove(),{once:true});root.append(dialog);
const start=new Date(day+'T'+(clock||'09:00')),end=new Date(start.getTime()+(metadata.settings?.default_allocation_minutes||60)*60000);
dialog.innerHTML=``;
dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();};
dialog.querySelector('form').onsubmit=async event=>{event.preventDefault();const form=event.target,button=form.querySelector('.btn-primary');button.disabled=true;
const employee=Number(form.elements.employee.value);
try {await saveWithConflict('/events',{method:'POST',body:JSON.stringify({sag_id:task.id,title:task.title,user_ids:[employee],kind:'work',starts_at:new Date(form.elements.start.value).toISOString(),ends_at:new Date(form.elements.end.value).toISOString()})});dialog.close();dialog.remove();target=employee;byId('workEmployee').value=employee;await loadQueue();await showTaskSource('queue');calendar.refetchEvents();taskMessage(`Et nyt tidsrum er tilføjet på sag #${task.id}. Eksisterende tider er bevaret.`);}
catch(err){form.querySelector('[role=alert]').textContent=err.message;button.disabled=false;}
};
window.BMCPlanningDates?.mount(dialog);dialog.showModal();
}
function calendarTaskTarget(event) {
const host=byId('workCalendar'),x=event.clientX,y=event.clientY;
let cell=event.target.closest('[data-date]');
// Time-grid lanes span every day. Their DOM ancestors have no date;
// resolve the overlaid day column from its rendered screen bounds.
if(!cell&&Number.isFinite(x)&&Number.isFinite(y)){
cell=[...host.querySelectorAll('.fc-timegrid-col[data-date],.fc-daygrid-day[data-date],.fc-col-header-cell[data-date]')].find(el=>{
const rect=el.getBoundingClientRect();
return rect.width>0&&rect.height>0&&x>=rect.left&&x=rect.top&&y{const rect=el.getBoundingClientRect();return event.clientY>=rect.top&&event.clientY{
const button=event.target.closest('[data-control-case]');if(!button||button.disabled)return;
const row=button.closest('[data-task-id]'),task=taskFromElement(row);if(!task)return;
if(button.dataset.taskKind==='slot')planTaskSlot(task);
else editTaskDates(task.id,button.dataset.taskKind).catch(error);
});
async function showTaskSource(source) {
if(currentView==='tasks'){taskSource=source;const url=new URL(location.href);url.searchParams.set('view','tasks');url.searchParams.set('source',source);url.searchParams.set('user_id',String(target));history.replaceState(null,'',url.pathname+url.search);}byId('workQueuePanel').hidden=currentView==='tasks'&&source!=='queue';byId('workUnassigned').hidden=currentView!=='tasks'||source!=='inbox';
root.querySelectorAll('[data-task-source].btn').forEach(button=>{button.classList.toggle('active',button.dataset.taskSource===source);button.setAttribute('aria-pressed',String(button.dataset.taskSource===source));});
if(currentView==='tasks'&&source==='inbox')await loadUnassigned();
}
function renderEmployeeDrops() {
byId('workEmployeeDrops').innerHTML=metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id)).map(u=>``).join('')+'
Træk hertil for at fjerne fra køen
';
}
byId('workSourceFilter').onclick=event=>{const button=event.target.closest('[data-task-source]');if(button)showTaskSource(button.dataset.taskSource).catch(error);};
byId('workEmployeeDrops').onclick=async event=>{const button=event.target.closest('[data-drop-user]');if(!button)return;target=Number(button.dataset.dropUser);byId('workEmployee').value=target;await loadQueue();await showTaskSource('queue');calendar.refetchEvents();};
root.addEventListener('dragstart',event=>{
const element=event.target.closest('[data-task-id]');if(!element)return;
taskDrag=taskFromElement(element);if(taskDrag)taskDrag.kind=event.target.closest('[data-task-kind]')?.dataset.taskKind||'reorder';if(!taskDrag){event.preventDefault();return;}
event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('application/x-bmc-task',String(taskDrag.id));event.dataTransfer.setData('text/plain',taskDrag.title);element.classList.add('is-dragging');
});
root.addEventListener('dragend',()=>{taskDrag=null;root.querySelectorAll('.is-dragging,.is-drag-over').forEach(el=>el.classList.remove('is-dragging','is-drag-over'));});
byId('workEmployeeDrops').addEventListener('dragover',event=>{if(!taskDrag||taskDrag.kind!=='reorder')return;const target=event.target.closest('[data-drop-user],[data-drop-unassign]');if(target){event.preventDefault();target.classList.add('is-drag-over');}});
byId('workEmployeeDrops').addEventListener('dragleave',event=>event.target.closest('.is-drag-over')?.classList.remove('is-drag-over'));
byId('workEmployeeDrops').addEventListener('drop',async event=>{if(!taskDrag||taskDrag.kind!=='reorder')return;event.preventDefault();event.stopPropagation();const button=event.target.closest('[data-drop-user],[data-drop-unassign]'),task={...taskDrag};if(!button)return;button.classList.remove('is-drag-over');if(button.hasAttribute('data-drop-unassign'))await unassignTask(task);else await assignTask(task,Number(button.dataset.dropUser));});
byId('workCalendar').addEventListener('dragover',event=>{if(!taskDrag||taskDrag.kind==='reorder'||(taskDrag.kind!=='slot'&&!canEditDates()))return;event.preventDefault();const {cell}=calendarTaskTarget(event);root.querySelectorAll('#workCalendar .is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));cell?.classList.add('is-drag-over');});
byId('workCalendar').addEventListener('drop',async event=>{if(!taskDrag)return;event.preventDefault();event.stopPropagation();const task={...taskDrag},{day,clock}=calendarTaskTarget(event);root.querySelectorAll('#workCalendar .is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));try{if(task.kind==='reorder')throw new Error('Træk knappen Start, Deadline eller Allokeret til kalenderen.');if(!day)throw new Error('Træk knappen til en dag i kalenderens uge- eller månedsvisning.');if(task.kind==='slot'){planTaskSlot(task,day,clock);return;}const instant=clock?(task.kind==='deadline'?new Date(day+'T'+clock).toISOString():day+'T'+clock+':00'):null;await setTaskDate(task,day,task.kind,instant);}catch(err){error(err);calendar.refetchEvents();}});
async function tab(requested) {
const aliases={day:'work',week:'work',calendar:'work',queue:'tasks',unassigned:'tasks',capacity:'team'};
const name=aliases[requested]||requested;currentView=['work','tasks','team'].includes(name)?name:'work';
if(requested==='queue')taskSource='queue';if(requested==='unassigned')taskSource='inbox';
if(new URLSearchParams(location.search).get('source')==='queue'||!metadata.permissions.some(p=>['planner.allocate','planner.admin'].includes(p)))taskSource='queue';
byId('workEmployeeToolbar').hidden=false;
root.classList.toggle('work-task-mode',currentView==='tasks');byId('workSourceFilter').querySelector('[data-task-source=inbox]').hidden=!metadata.permissions.some(p=>['planner.allocate','planner.admin'].includes(p));
document.querySelectorAll('[data-work-tab]').forEach(b=>{b.classList.toggle('active',b.dataset.workTab===currentView);b.setAttribute('aria-pressed',String(b.dataset.workTab===currentView));});
const daily=currentView!=='team';byId('workDaily').hidden=!daily;byId('workTaskBar').hidden=currentView!=='tasks';byId('workSourceFilter').hidden=currentView!=='tasks';byId('workTeam').hidden=daily;byId('workCapacity').hidden=daily;
if(!daily&&!byId('workTeam').src)byId('workTeam').src='/planner?embedded=1';
await showTaskSource(currentView==='tasks'?taskSource:'queue');
if(daily){const view=requested==='week'?'timeGridWeek':requested==='calendar'?'dayGridMonth':currentView==='tasks'?'timeGridWeek':innerWidth<800?'listDay':'timeGridDay';calendar.changeView(view);calendar.updateSize();calendar.refetchEvents();}
if(name==='team'){const start=new Date();start.setHours(0,0,0,0);const end=new Date(start);end.setDate(end.getDate()+7);const data=await api(`/capacity?starts_at=${encodeURIComponent(start.toISOString())}&ends_at=${encodeURIComponent(end.toISOString())}&user_id=${target}`);byId('workCapacity').innerHTML=data.items.map(r=>`
Overbelastning (prognose): ${r.norm_configured?minutes(r.overload_minutes):'kan ikke beregnes'} · Ufordelte estimater: ${r.unestimated_count}
${(r.deadline_risks||[]).map(w=>`
#${w.sag_id} · ${esc(({overdue:"Deadline overskredet",booking_after_deadline:"Arbejde planlagt efter deadline",unallocated_estimate:"Estimat mangler fordeling",insufficient_capacity:"Risiko: for lidt tid før deadline"})[w.kind]||w.kind)}