/* Unified planning uses existing cases and time APIs. Opening work never starts time. */ (() => { 'use strict'; const root = document.querySelector('.work-page'); if (!root) return; const uid = Number(root.dataset.userId); const byId = id => document.getElementById(id); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const local = date => new Date(date.getTime() - date.getTimezoneOffset()*60000).toISOString().slice(0,16); const api = async (path, options={}) => { const response = await fetch('/api/v1/planner'+path, {...options, headers: {'Content-Type':'application/json', ...(options.headers||{})}}); const text = await response.text(); let body; try { body = JSON.parse(text); } catch (_) { throw new Error(response.status >= 500 ? 'Planlægningen kunne ikke indlæses på grund af en serverfejl. Prøv igen efter databaseopdateringen.' : 'Serveren returnerede et ugyldigt svar. Genindlæs siden eller log ind igen.'); } if (!response.ok) { const e = new Error(typeof body.detail === 'string' ? body.detail : body.detail?.message || 'Handlingen kunne ikke gennemføres'); e.detail=body.detail; throw e; } return body; }; let queue = {items:[],version:1}, metadata, calendar, dragged, target=uid, queueRequest=0, activityTypes=[]; const error = e => {byId('workError').textContent=e.message;byId('workError').hidden=false;}; const minutes = n => n == null ? 'Ukendt estimat' : `${Math.round(n)} min`; function taskControls(row) { const slots=row.planned_slots||[],id=row.sag_id||row.id; const day=value=>value?String(value).split('-').reverse().join('.'):'Ikke sat'; const start=day(row.start_date)+(row.start_time?' kl. '+row.start_time.slice(0,5):''); const deadline=row.deadline?new Intl.DateTimeFormat('da-DK',{dateStyle:'short',timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(row.deadline)):'Ikke sat'; const buttons=[['start','Start','bi-calendar-plus',start,canEditDates()],['deadline','Deadline','bi-flag',deadline,canEditDates()],['slot','Allokeret','bi-calendar2-range',slots.length?`${slots.length} tidsrum`:'Ingen tider',metadata.editable_user_ids.includes(target)]]; return `
${buttons.map(([kind,label,icon,value,allowed])=>``).join('')}
${slots.length?`
Kommende tider · ${esc(metadata.users.find(u=>u.id===target)?.name||'')}
`:''}`; } function reservationEvent(row,viewType) { const reserved=row.kind==='work'||row.private; const background=reserved&&viewType.startsWith('timeGrid'); return {id:'event-'+(row.event_id??row.id),title:(row.has_conflict||row.outside_work_hours?'⚠ ':'')+(reserved?'Allokeret: ':'')+row.title,start:row.starts_at,end:row.ends_at, display:background?'background':'auto',color:row.has_conflict?'#b42318':row.outside_work_hours?'#b54708':undefined, classNames:reserved?['work-reservation',...(row.has_conflict?['work-reservation-conflict']:[])]:[], editable:!background&&!row.locked&&canModifyEvent(row), extendedProps:{booking:row.private?null:row,reservation:reserved}}; } function reservationMount(info) { if(!info.event.extendedProps.reservation||info.event.display!=='background')return; const booking=info.event.extendedProps.booking; const label=document.createElement(booking?'button':'span'); label.className='work-reservation-label';label.textContent=info.event.title; if(booking){label.type='button';label.title='Se eller ændr den reserverede tid';label.onclick=event=>{event.stopPropagation();event.preventDefault();eventDialog(booking);};} info.el.append(label); } function renderQueue() { const filter=byId('workFilter').value, search=byId('workSearch').value.toLocaleLowerCase(); const today=local(new Date()).slice(0,10); const rows=queue.items.filter(r => { if (search && ![r.titel,r.customer_name,r.type,r.sag_id,...(r.tags||[])].join(' ').toLocaleLowerCase().includes(search)) return false; if (!filter) return true; if (filter==='ready') return r.ready; if (filter==='high') return ['urgent','high'].includes(r.priority); if (filter==='today') return String(r.deadline||'').slice(0,10)===today; if (filter==='overdue') return r.deadline && new Date(r.deadline)u.id===target)?.name||'Medarbejder'); byId('workQueue').innerHTML=rows.map(r=>`
${esc(r.customer_name||'')} · ${esc(minutes(r.remaining_work_minutes))} · ${esc(({ready:'Klar',working:'I gang',paused:'På pause',waiting:'Afventer',done:'Færdigt'})[r.work_status]||r.work_status)}${r.active_timer?' · Timer aktiv':''}
${taskControls(r)}
Flere handlinger
`).join('')||'

Ingen opgaver i denne visning.

'; } async function loadQueue(more=false) { const requestId=++queueRequest,limit=more?100:Math.min(500,Math.max(100,queue.items.length));const result=await api('/queue?user_id='+target+'&q='+encodeURIComponent(byId('workSearch').value)+'&limit='+limit+'&offset='+(more?queue.items.length:0));if(requestId!==queueRequest)return; queue={...result,items:more?[...queue.items,...result.items]:result.items}; byId('workMore').hidden=result.items.length { const button=event.target.closest('[data-action]');if(!button)return; const id=Number(button.dataset.id), row=queue.items.find(r=>r.sag_id===id), action=button.dataset.action; if(action==='pin')return change(id,{pinned:!row.pinned}); if(action==='pause'){if(target!==uid)return change(id,{work_status:row.work_status==='paused'?'ready':'paused'});const mode=row.work_status==='paused'?'resume':'pause';const timer_action=confirm(mode==='pause'?'Vil du også pause timeren på denne sag?':'Vil du også genoptage en pauset timer på denne sag?');try{await api(`/queue/${id}/${mode}`,{method:'POST',body:JSON.stringify({version:queue.version,timer_action})});await loadQueue();}catch(e){error(e);}return;} if(action==='planning')return editTaskDates(id).catch(error); if(action==='details')return editCasePlan(id).catch(error); if(action==='unassign')return unassignTask({id,source:target,version:queue.version}).catch(error); if(action==='transfer')return transferTask(row).catch(error); if(action==='done')return change(id,{work_status:row.work_status==='done'?'ready':'done'}); if(action==='estimate') {const value=prompt('Individuelt estimat i minutter (tomt = ufordelt)',row.estimated_minutes??'');if(value===null)return;if(value!==''&&(!Number.isInteger(Number(value))||Number(value)<0))return error(new Error('Angiv et helt antal minutter'));return change(id,{estimated_minutes:value===''?null:Number(value)});} if(action==='defer') {const value=prompt('Udsæt til dato (ÅÅÅÅ-MM-DD)');if(!value)return;const date=new Date(value+'T08:00:00');if(Number.isNaN(date.getTime()))return error(new Error('Ugyldig dato'));return change(id,{snoozed_until:date.toISOString()});} const group=queue.items.filter(r=>r.pinned===row.pinned),index=group.indexOf(row); if(action==='up' && index>0)return change(id,{before_sag_id:group[index-1].sag_id}); if(action==='down' && index{if(taskDrag?.source===target&&taskDrag.kind==='reorder')e.preventDefault();}); byId('workQueue').addEventListener('drop',async e=>{if(!taskDrag||taskDrag.source!==target||taskDrag.kind!=='reorder')return;e.preventDefault();e.stopPropagation();const before=Number(e.target.closest('[data-task-id]')?.dataset.taskId);if(before===taskDrag.id)return;try{await api(`/queue/${target}/${taskDrag.id}`,{method:'PATCH',body:JSON.stringify({version:taskDrag.version,before_sag_id:before||null})});await loadQueue();taskMessage('Rækkefølgen er gemt.');}catch(err){error(err);await loadQueue();}}); byId('workFilter').addEventListener('change',renderQueue);let searchTimer;byId('workSearch').addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{queue.items=[];loadQueue().catch(error);},200);}); byId('workRefresh').onclick=()=>loadQueue().catch(error);byId('workMore').onclick=()=>loadQueue(true).catch(error); document.querySelectorAll('[data-work-close]').forEach(b=>b.onclick=()=>b.closest('dialog').close()); const canModifyEvent=event=>(event.participants||[event.user_id]).every(id=>metadata.editable_user_ids.includes(id)); function eventDialog(event=null,start=new Date()) { const form=byId('workEventForm');window.BMCPlanningDates?.reset(form);form.reset();form.querySelector('.work-dialog-error').textContent=''; form.dataset.originalStart=event?.starts_at||'';form.dataset.originalEnd=event?.ends_at||'';form.elements.event_id.value=event?.event_id||'';form.elements.version.value=event?.event_version||''; for(const name of ['title','description','sag_id','kind']) if(event?.[name]!=null)form.elements[name].value=event[name];form.elements.case_search.value=event?.sag_id||''; for(const option of form.elements.user_ids.options)option.selected=event?.participants?.includes(Number(option.value))??Number(option.value)===uid; form.elements.starts_at.value=local(event?new Date(event.starts_at):start); form.elements.ends_at.value=local(event?new Date(event.ends_at):new Date(start.getTime()+3600000)); const editable=event?canModifyEvent(event)&&!event.locked:metadata.editable_user_ids.length>0;for(const field of form.querySelectorAll('input,select,textarea'))field.disabled=!editable;form.querySelector('.btn-primary').hidden=!editable;byId('workDeleteEvent').hidden=!event||!editable;window.BMCPlanningDates?.mount(form);byId('workEventDialog').showModal(); } let caseSearchTimer,lastAutoTitle=''; byId('workEventForm').elements.case_search.oninput=e=>{ clearTimeout(caseSearchTimer);const value=e.target.value.trim(),form=byId('workEventForm');form.elements.sag_id.value=/^\d+$/.test(value)?value:''; if(!value){byId('workCaseSuggestions').innerHTML='';return;} caseSearchTimer=setTimeout(async()=>{try{const result=await api('/cases?q='+encodeURIComponent(value)+'&limit=20');byId('workCaseSuggestions').innerHTML=result.items.map(r=>``).join('');const row=result.items.find(r=>String(r.id)===value);if(row&&(!form.elements.title.value||form.elements.title.value===lastAutoTitle)){form.elements.title.value=row.title;lastAutoTitle=row.title;}}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}},200); }; byId('workCreate').onclick=()=>eventDialog(); byId('workAbsence').onclick=()=>absenceDialog(); async function transferTask(row) { const dialog=document.createElement('dialog');root.append(dialog); dialog.innerHTML=`

Overdrag arbejde · #${row.sag_id}

Den ansvarlige på sagen bevares.

`; dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();}; dialog.querySelector('form').onsubmit=async e=>{e.preventDefault();const to=Number(e.target.elements.employee.value);try{const destination=await api('/queue?user_id='+to+'&limit=1');if(!await transferWithBookings(row.sag_id,{source_user_id:target,target_user_id:to,source_version:queue.version,target_version:destination.version}))return;dialog.close();dialog.remove();await loadQueue();calendar.refetchEvents();}catch(err){dialog.querySelector('[role=alert]').textContent=err.message;}}; window.BMCPlanningDates?.mount(dialog);dialog.showModal(); } async function editSettings(config) { const schedule=await api('/work-schedules/'+target),dialog=document.createElement('dialog');root.append(dialog); const labels=['Mandag','Tirsdag','Onsdag','Torsdag','Fredag','Lørdag','Søndag']; dialog.innerHTML=`

Planlægningsindstillinger


Arbejdstid · ${esc(metadata.users.find(u=>u.id===target)?.name)}

${labels.map((label,d)=>{const row=schedule.find(r=>r.weekday===d);return `
`;}).join('')}

Sidste kolonne er pause i minutter.


Aktivitets- og fraværstyper

`; dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();}; dialog.querySelector('[data-general]').onsubmit=async e=>{e.preventDefault();try{Object.assign(config,await api('/configuration',{method:'PUT',body:JSON.stringify({appointment_warning_minutes:Number(e.target.elements.warning.value),deadline_warning_days:Number(e.target.elements.deadline_days.value)})}));e.target.querySelector('[role=status]').textContent='Gemt';}catch(err){e.target.querySelector('[role=status]').textContent=err.message;}}; dialog.querySelector('[data-schedule]').onsubmit=async e=>{e.preventDefault();const f=e.target;const days=labels.map((_,d)=>d).filter(d=>f.elements['active_'+d].checked).map(d=>({weekday:d,start_time:f.elements['start_'+d].value,end_time:f.elements['end_'+d].value,break_minutes:Number(f.elements['break_'+d].value)}));try{await api('/work-schedules/'+target,{method:'PUT',body:JSON.stringify({days})});f.querySelector('[role=status]').textContent='Gemt';calendar.refetchEvents();}catch(err){f.querySelector('[role=status]').textContent=err.message;}}; const types=await api('/activity-types'),form=dialog.querySelector('[data-type]');form.elements.code.insertAdjacentHTML('beforeend',types.map(t=>``).join('')); form.elements.code.onchange=()=>{const value=types.find(t=>t.code===form.elements.code.value);form.elements.label.value=value?.label||'';form.elements.active.checked=value?.active??true;form.elements.absence.checked=value?.is_absence??true;}; form.onsubmit=async e=>{e.preventDefault();const code=form.elements.code.value||form.elements.label.value.toLowerCase().replaceAll('æ','ae').replaceAll('ø','oe').replaceAll('å','aa').replace(/[^a-z]+/g,'_').replace(/^_|_$/g,'').slice(0,40);try{await api('/activity-types/'+code,{method:'PUT',body:JSON.stringify({label:form.elements.label.value,active:form.elements.active.checked,is_absence:form.elements.absence.checked})});form.querySelector('[role=status]').textContent='Gemt';const all=await api('/activity-types');activityTypes=all;byId('workAbsenceForm').elements.kind.innerHTML=all.filter(t=>t.active&&t.is_absence).map(t=>``).join('');calendar.refetchEvents();}catch(err){form.querySelector('[role=status]').textContent=err.message;}}; const scope=await api('/team-scope/'+target),scopeForm=document.createElement('form'); scopeForm.innerHTML=`

Adgang til kollegers planlægning

Gælder ${esc(metadata.users.find(u=>u.id===target)?.name)}. Medarbejderens rolle afgør, om der kan ses eller redigeres.

`; dialog.insertBefore(scopeForm,dialog.querySelector('[data-close]')); scopeForm.onsubmit=async e=>{e.preventDefault();try{await api('/team-scope/'+target,{method:'PUT',body:JSON.stringify({user_ids:[...scopeForm.elements.employees.selectedOptions].map(o=>Number(o.value))})});scopeForm.querySelector('[role=status]').textContent='Gemt';}catch(err){scopeForm.querySelector('[role=status]').textContent=err.message;}}; window.BMCPlanningDates?.mount(dialog);dialog.showModal(); } async function editCasePlan(id) { const row=await api(`/cases/${id}/planning`), dialog=document.createElement('dialog'),canEditCase=metadata.permissions.some(p=>['cases.edit','tickets.edit'].includes(p));root.append(dialog); const dateTime=v=>v?local(new Date(v)):''; dialog.innerHTML=`

Sagsplan · #${id}

${esc(row.titel)}

Fordeling mellem medarbejdere

${row.assignees.map(a=>`
${esc(a.name)}
`).join('')}

Tomt resterende arbejde beregnes fra individuelt estimat og registreret tid. Sagens samlede estimat fordeles ikke automatisk.

${metadata.permissions.some(p=>['planner.admin','planner.allocate'].includes(p))?``:''}
`; dialog.querySelector('[data-close]').onclick=()=>{dialog.close();dialog.remove();}; if(!canEditCase)for(const name of ['start_date','start_time','deadline','estimated_minutes'])dialog.querySelector('[name='+name+']').disabled=true; if(dialog.querySelector('[name=new_user]'))dialog.querySelector('[name=new_user]').onchange=e=>{dialog.querySelector('[name=new_minutes]').disabled=!e.target.value;}; dialog.querySelector('form').onsubmit=async e=>{e.preventDefault();const data=new FormData(e.target);const body={updated_at:row.updated_at,start_date:data.get('start_date')||null,start_time:data.get('start_time')||null,deadline:data.get('deadline')===dateTime(row.deadline)?row.deadline:data.get('deadline')?new Date(data.get('deadline')).toISOString():null,estimated_minutes:data.get('estimated_minutes')===''?null:Number(data.get('estimated_minutes')),assignee_estimates:row.assignees.filter(a=>metadata.editable_user_ids.includes(a.user_id)).map(a=>({user_id:a.user_id,queue_version:a.queue_version,estimated_minutes:data.get('estimate_'+a.user_id)===''?null:Number(data.get('estimate_'+a.user_id)),remaining_minutes:data.get('remaining_'+a.user_id)===''?null:Number(data.get('remaining_'+a.user_id))})),add_assignee:data.get('new_user')?{user_id:Number(data.get('new_user')),estimated_minutes:data.get('new_minutes')===''?null:Number(data.get('new_minutes'))}:null};if(!canEditCase)for(const key of ['start_date','start_time','deadline','estimated_minutes'])delete body[key];try{await api(`/cases/${id}/planning`,{method:'PATCH',body:JSON.stringify(body)});dialog.close();dialog.remove();await loadQueue();calendar.refetchEvents();}catch(err){dialog.querySelector('[role=alert]').textContent=err.message;}}; window.BMCPlanningDates?.mount(dialog);dialog.showModal(); } async function saveWithConflict(path,options) { try{return await api(path,options);}catch(e){if(e.detail?.confirm_conflicts && confirm(e.message+'. Vil du gemme alligevel?'))return api(path,{...options,body:JSON.stringify({...JSON.parse(options.body),confirm_conflicts:true})});throw e;} } byId('workEventForm').onsubmit=async e=>{ e.preventDefault();const form=e.target,data=new FormData(form), id=data.get('event_id'); if(data.get('case_search')&&!data.get('sag_id')){form.querySelector('.work-dialog-error').textContent='Vælg en sag fra forslagene, eller ryd søgefeltet.';return;}const body={title:data.get('title'),description:data.get('description'),sag_id:Number(data.get('sag_id'))||null,kind:data.get('kind'),user_ids:data.getAll('user_ids').map(Number),starts_at:form.dataset.originalStart&&data.get('starts_at')===local(new Date(form.dataset.originalStart))?form.dataset.originalStart:new Date(data.get('starts_at')).toISOString(),ends_at:form.dataset.originalEnd&&data.get('ends_at')===local(new Date(form.dataset.originalEnd))?form.dataset.originalEnd:new Date(data.get('ends_at')).toISOString(),version:Number(data.get('version'))||null}; try{await saveWithConflict('/events'+(id?'/'+id:''),{method:id?'PATCH':'POST',body:JSON.stringify(body)});byId('workEventDialog').close();calendar.refetchEvents();await loadQueue();}catch(err){form.querySelector('.work-dialog-error').textContent=err.message;} }; byId('workDeleteEvent').onclick=async()=>{const form=byId('workEventForm');if(!confirm('Annullér aftalen for alle deltagere?'))return;try{await api(`/events/${form.elements.event_id.value}?version=${form.elements.version.value}`,{method:'DELETE'});byId('workEventDialog').close();calendar.refetchEvents();}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}}; function absenceEvent(row) { const label=row.kind==='unavailable'?'Fravær':activityTypes.find(t=>t.code===row.kind)?.label||'Fravær'; return {id:'absence-'+row.id,title:label,start:row.all_day?row.display_start:row.starts_at, end:row.all_day?row.display_end:row.ends_at,allDay:!!row.all_day,color:'#64748b', editable:false,extendedProps:{absence:row}}; } let visibleAbsences=[]; function renderAbsences(rows) { visibleAbsences=rows; byId('workAbsenceSummary').innerHTML='Ferie og fravær · vist kalenderperiode'+(rows.length?'
'+rows.map(r=>``).join('')+'
':'Intet planlagt fravær i den viste periode. Vælg uge eller måned for et større overblik.'); } byId('workAbsenceSummary').onclick=e=>{const button=e.target.closest('[data-absence-id]');if(button)absenceDialog(visibleAbsences.find(r=>r.id===Number(button.dataset.absenceId)));}; function absenceDialog(row=null) { const form=byId('workAbsenceForm');window.BMCPlanningDates?.reset(form);form.reset();form.querySelector('.work-dialog-error').textContent=''; const canRegister=id=>metadata.editable_user_ids.includes(id)&&metadata.permissions.some(p=>p==='planner.admin'||p===(id===uid?'planner.absence_own':'planner.absence_team')); const allowed=metadata.users.filter(u=>canRegister(u.id)),selected=row?.user_id||(allowed.some(u=>u.id===target)?target:allowed[0]?.id); const editable=!!selected&&canRegister(selected)&&!row?.registered,reasonVisible=!row||row.kind!=='unavailable'; form.elements.user_id.innerHTML=metadata.users.map(u=>``).join(''); form.elements.kind.innerHTML=reasonVisible?activityTypes.filter(t=>t.is_absence&&(t.active||t.code===row?.kind)).map(t=>``).join(''):''; form.dataset.originalStart=row?.starts_at||'';form.dataset.originalEnd=row?.ends_at||'';form.elements.absence_id.value=row?.id||'';form.elements.version.value=row?.version||''; if(row){form.elements.starts_at.value=local(new Date(row.starts_at));form.elements.ends_at.value=local(new Date(row.ends_at));form.elements.note.value=row.note||'';} form.elements.user_id.disabled=!!row||!editable;form.elements.kind.disabled=!editable||!reasonVisible;form.elements.note.disabled=!editable||!reasonVisible; form.elements.whole_days.checked=!row||!!row.whole_days; form.elements.whole_days.disabled=!editable; const changePeriodMode=()=>{ window.BMCPlanningDates?.reset(form); const whole=form.elements.whole_days.checked; for(const name of ['starts_at','ends_at']) { const input=form.elements[name],value=input.value; input.type=whole?'date':'datetime-local';input.value=whole?value.slice(0,10):value.length===10?value+'T08:00':value; } }; changePeriodMode(); if(row&&form.elements.whole_days.checked){const last=new Date(row.display_end+'T12:00:00Z');last.setUTCDate(last.getUTCDate()-1);form.elements.starts_at.value=row.display_start;form.elements.ends_at.value=last.toISOString().slice(0,10);} form.elements.whole_days.onchange=()=>{changePeriodMode();window.BMCPlanningDates?.mount(form);}; form.elements.starts_at.disabled=!editable;form.elements.ends_at.disabled=!editable; form.querySelector('.btn-primary').hidden=!editable;byId('workCancelAbsence').hidden=!row||!editable; byId('workRegisterAbsence').hidden=!row||!editable||new Date(row.ends_at)>new Date(); window.BMCPlanningDates?.mount(form);byId('workAbsenceDialog').showModal(); } byId('workAbsenceForm').onsubmit=async e=>{e.preventDefault();const form=e.target,data=new FormData(form),id=data.get('absence_id');const body={user_id:Number(form.elements.user_id.value),kind:form.elements.kind.disabled?null:data.get('kind'),starts_at:form.dataset.originalStart&&data.get('starts_at')===local(new Date(form.dataset.originalStart))?form.dataset.originalStart:new Date(data.get('starts_at')).toISOString(),ends_at:form.dataset.originalEnd&&data.get('ends_at')===local(new Date(form.dataset.originalEnd))?form.dataset.originalEnd:new Date(data.get('ends_at')).toISOString(),version:Number(data.get('version'))||null};if(form.elements.whole_days.checked){body.whole_day_start=data.get('starts_at');body.whole_day_end=data.get('ends_at');}if(!form.elements.note.disabled)body.note=data.get('note');try{const r=await api('/absences'+(id?'/'+id:''),{method:id?'PATCH':'POST',body:JSON.stringify(body)});byId('workAbsenceDialog').close();calendar.refetchEvents();if(r.affected_allocations.length)alert(`${r.affected_allocations.length} bookinger påvirkes af fraværet.`);}catch(err){form.querySelector('.work-dialog-error').textContent=err.message;}}; byId('workRegisterAbsence').onclick=async()=>{const form=byId('workAbsenceForm');if(!confirm('Registrer fraværet som faktisk afholdt, ikke-fakturerbar tid?'))return;try{await api(`/absences/${form.elements.absence_id.value}/register?version=${form.elements.version.value}`,{method:'POST'});byId('workAbsenceDialog').close();calendar.refetchEvents();}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}}; byId('workCancelAbsence').onclick=async()=>{const form=byId('workAbsenceForm');if(!confirm('Annullér det planlagte fravær?'))return;try{await api(`/absences/${form.elements.absence_id.value}?version=${form.elements.version.value}`,{method:'DELETE'});byId('workAbsenceDialog').close();calendar.refetchEvents();}catch(e){form.querySelector('.work-dialog-error').textContent=e.message;}}; async function next() { const result=await api('/next-task'); if(result.task&&!result.warning){location.assign(result.task.url);return;} byId('workNextMessage').textContent=result.warning?`Kommende aftale ${new Date(result.appointment.starts_at).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'})}. Næste opgave: ${result.task.title} (${minutes(result.task.remaining_minutes)}).`:result.message; const actions=[];if(result.task)actions.push([result.task.url,'Åbn opgaven alligevel']);if(result.appointment?.sag_id)actions.push(['/sag/'+result.appointment.sag_id,'Åbn fast aftale']);if(result.shorter_task)actions.push([result.shorter_task.url,'Vælg kortere opgave']);actions.push(['/planning?view=queue','Vis arbejdskø'],['/planning?view=calendar','Vis kalender']); byId('workNextActions').innerHTML=actions.map(([url,label])=>`${esc(label)}`).join('');byId('workNextDialog').showModal(); } document.querySelectorAll('[data-work-next]').forEach(b=>b.onclick=()=>next().catch(error)); let unassignedRows=[],unassignedOffset=0,unassignedRequest=0,unassignedSearchTimer; const helpBubble=(text,label)=>`
${esc(text)}
`; async function loadUnassigned(offset=0) { const request=++unassignedRequest,limit=25; byId('unassignedList').setAttribute('aria-busy','true');byId('unassignedError').hidden=true; byId('unassignedStatus').textContent='Henter sager…'; byId('unassignedPrev').disabled=true;byId('unassignedNext').disabled=true; try { const query=byId('unassignedSearch').value.trim(); const data=await api(`/unassigned?limit=${limit}&offset=${offset}&q=${encodeURIComponent(query)}`); if(request!==unassignedRequest)return; if(!data.items.length&&offset>0)return loadUnassigned(Math.max(0,offset-limit)); unassignedRows=data.items;unassignedOffset=offset; byId('unassignedList').innerHTML=data.items.length?`
${data.items.map(row=>{ const deadline=row.deadline?new Date(row.deadline):null; const overdue=deadline&&deadline`; }).join('')}
Sag / kunde og planlægningSagens estimatHandling
#${row.id}${esc(row.titel)}${priority?` ${priority}`:''}
${esc(row.customer_name||'Ingen kunde')} · Ansvarlig: ${esc(row.responsible_name||'Ikke valgt')}
${taskControls(row)}
${deadline?esc(deadline.toLocaleDateString('da-DK',{timeZone:'Europe/Copenhagen'})):'Ingen deadline'}${overdue?'
Overskredet
':''}
${row.estimated_minutes==null?'Ikke angivet':esc(row.estimated_minutes)+' min'}
`:`

${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=`

Tilføj sag til en medarbejders kø

#${row.id} ${esc(row.titel)}

${esc(row.customer_name||'Ingen kunde')}

${helpBubble('Angiv kun den tid, denne medarbejder forventes at bruge på sagen. 60 minutter er 1 time. Lad feltet stå tomt, hvis du ikke ved det endnu. Sagens samlede estimat ændres ikke.','Hjælp til forventet arbejdstid')}
minutter
Sagen tilføjes nederst i medarbejderens kø. Den ansvarlige på sagen ændres ikke.
Skal arbejdet også lægges i kalenderen?

Kun hvis der er aftalt et bestemt tidspunkt. Tilføj først sagen til køen. Brug derefter “Ny aftale” til at reservere tid i kalenderen.

${employees.length?'':'

Du har ikke adgang til at tildele arbejde til en medarbejder.

'}
`; 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=`

${kind==='start'?'Sæt start':kind==='deadline'?'Sæt deadline':'Start og deadline'}

#${id} ${esc(plan.titel)}

Startdato er den tidligste dag, arbejdet kan begynde. Deadline er dagen, det skal være færdigt. En ny deadline sættes kl. 17; et eksisterende klokkeslæt bevares.

`; 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=`

Allokér et nyt tidsrum

#${task.id} ${esc(task.title)}

Tiden reserveres til opgaven. Den faktiske arbejdstid registrerer du bagefter med timeren eller manuelt. Eksisterende reservationer bevares, og du kan tilføje flere tidsrum.

`; 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=>`

${esc(r.name)}

Normtid ${r.norm_configured?minutes(r.normal_minutes):'ikke opsat'} · Optaget ${minutes(r.busy_minutes)} · Ledig ${r.norm_configured?minutes(r.free_minutes):'ukendt'} · Kø ${minutes(r.queue_minutes)}

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

`).join('')}
`).join('');} } document.querySelectorAll('[data-work-tab]').forEach(b=>b.onclick=()=>{history.replaceState(null,'','?view='+b.dataset.workTab);tab(b.dataset.workTab).catch(error);}); async function init() { metadata=await api('/metadata');byId('workAbsenceReport').hidden=!metadata.can_view_absence_report;renderEmployeeDrops();const requestedEmployee=Number(new URLSearchParams(location.search).get('user_id'));if(metadata.users.some(u=>u.id===requestedEmployee))target=requestedEmployee;const types=await api('/activity-types');activityTypes=types;byId('workEmployee').innerHTML=metadata.users.map(u=>``).join('');byId('workEmployee').onchange=async()=>{target=Number(byId('workEmployee').value);try{await loadQueue();calendar.refetchEvents();await tab(new URLSearchParams(location.search).get('view')||'day');}catch(e){error(e);}};const config=await api('/configuration');if(config.can_manage){byId('workSettings').hidden=false;byId('workSettings').onclick=()=>editSettings(config).catch(error);} for(const select of root.querySelectorAll('select[name=user_id],select[name=user_ids]')){select.innerHTML=metadata.users.map(u=>``).join('');} byId('workAbsenceForm').elements.kind.innerHTML=types.filter(t=>t.active&&t.is_absence).map(t=>``).join(''); calendar=new FullCalendar.Calendar(byId('workCalendar'),{locale:'da',firstDay:1,initialView:innerWidth<800?'listDay':'timeGridDay',height:'auto',scrollTime:'08:00:00',eventDurationEditable:false,headerToolbar:{left:'prev,next today',center:'title',right:'timeGridDay,timeGridWeek,dayGridMonth,listWeek'},buttonText:{today:'I dag',day:'Dag',week:'Uge',month:'Måned',list:'Liste'},editable:true,eventDidMount:reservationMount,dateClick:info=>eventDialog(null,info.date),events:async(info,success,failure)=>{try{const data=await api(`/schedule?starts_at=${encodeURIComponent(info.startStr)}&ends_at=${encodeURIComponent(info.endStr)}&user_id=${target}&include_unassigned=${currentView==='tasks'}`);const grouped=new Map();data.bookings.forEach(r=>{const key=r.event_id||'booking-'+r.id;if(!grouped.has(key))grouped.set(key,{...r,participants:r.participants||[]});if(!grouped.get(key).participants.includes(r.user_id))grouped.get(key).participants.push(r.user_id);});const events=[...grouped.values()].map(r=>reservationEvent(r,calendar.view.type));data.absences.forEach(r=>events.push(absenceEvent(r)));renderAbsences(data.absences);data.standalone_plans.forEach(r=>events.push({id:'quick-'+r.id,title:r.title,start:r.starts_at,end:r.ends_at,color:'#336699',editable:false}));(data.milestones||[]).forEach(r=>events.push({id:r.id,title:r.title,start:r.start,allDay:r.allDay||false,color:r.kind==='deadline'?'#b42318':'#475467',editable:['case_start','deadline'].includes(r.kind)&&canEditDates(),durationEditable:false,extendedProps:{caseUrl:'/sag/'+r.sag_id,milestone:r}}));(data.registered||[]).forEach(r=>events.push({id:'registered-'+r.id,title:'Registreret: '+r.title,start:r.starts_at,end:r.ends_at,color:'#7c3aed',editable:false,extendedProps:{caseUrl:r.sag_id?'/sag/'+r.sag_id:null}}));(data.external_busy||[]).forEach(r=>events.push({id:'external-'+r.id,title:'Optaget eksternt',start:r.starts_at,end:r.ends_at,color:'#94a3b8',editable:false}));success(events);}catch(e){failure(e);}},eventClick:async info=>{if(['case_start','deadline'].includes(info.event.extendedProps.milestone?.kind)){await editTaskDates(info.event.extendedProps.milestone.sag_id).catch(error);return;}if(info.event.extendedProps.caseUrl){location.assign(info.event.extendedProps.caseUrl);return;}if(info.event.extendedProps.booking)eventDialog(info.event.extendedProps.booking);else if(info.event.extendedProps.absence){absenceDialog(info.event.extendedProps.absence);}},eventDrop:async info=>{const marker=info.event.extendedProps.milestone;if(marker){try{await setTaskDate({id:marker.sag_id,updated_at:marker.case_updated_at},info.event.startStr.slice(0,10),marker.kind==='deadline'?'deadline':'start',info.event.allDay?null:info.event.startStr);}catch(err){info.revert();error(err);}return;}const r=info.event.extendedProps.booking;if(!r){info.revert();return;}try{await saveWithConflict('/events/'+r.event_id,{method:'PATCH',body:JSON.stringify({title:r.title,description:r.description,sag_id:r.sag_id,kind:r.kind||'work',user_ids:r.participants,version:r.event_version,starts_at:info.event.start.toISOString(),ends_at:info.event.end.toISOString()})});calendar.refetchEvents();}catch(e){info.revert();error(e);}},eventResize:()=>calendar.refetchEvents()}); calendar.render();byId('workCreate').disabled=!metadata.editable_user_ids.length;byId('workAbsence').disabled=!metadata.editable_user_ids.some(id=>metadata.permissions.some(p=>p==='planner.admin'||p===(id===uid?'planner.absence_own':'planner.absence_team')));await loadQueue();await tab(new URLSearchParams(location.search).get('view')||'day'); if(new URLSearchParams(location.search).has('next'))await next();const caseId=Number(new URLSearchParams(location.search).get('case_id'));if(caseId>0)await editCasePlan(caseId);setInterval(()=>{if(document.hidden||taskDrag||taskBusy||root.querySelector('dialog[open]'))return;loadQueue().catch(()=>{});calendar.refetchEvents();if(currentView==='team')tab('team').catch(()=>{});},30000); } init().catch(error); })();