/* The desk is a view of existing queues, bookings and actual time. */ (() => { 'use strict'; const root = document.getElementById('workDesk'); if (!root) return; const $ = id => document.getElementById(id); const uid = Number(root.dataset.userId); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c])); const local = date => new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16); const dayKey = date => local(date).slice(0,10); const fmt = minutes => minutes == null ? 'Ikke angivet' : `${Math.floor(minutes/60) ? Math.floor(minutes/60)+' t' : ''}${minutes%60 ? ' '+Math.round(minutes%60)+' min' : ''}`.trim() || '0 min'; const clock = value => new Date(value).toLocaleTimeString('da-DK', {hour:'2-digit',minute:'2-digit'}); const dateLabel = value => value ? new Date(value.length===10?value+'T12:00:00':value).toLocaleDateString('da-DK',{day:'numeric',month:'short'}) : 'Ikke sat'; const planner = '/api/v1/planner'; let calendarScrolledDay=null, workHours=[], desk, metadata, calendar, schedule = {}, selected = null, recent = [], undo = null, drag = null, busy = false, activeDay = dayKey(new Date()), requestSeq = 0; async function api(path, options = {}) { const response = await fetch(path, {...options, headers:{'Content-Type':'application/json',...(options.headers||{})}}); let result; try {result=await response.json();} catch (_) {throw new Error('Serveren gav et ugyldigt svar. Genindlæs eller log ind igen.');} if (!response.ok) { const error = new Error(typeof result.detail==='string'?result.detail:result.detail?.message || 'Handlingen kunne ikke gennemføres'); error.status=response.status;error.detail=result.detail;throw error; } return result; } async function mutation(path, method, body) { try {return await api(path,{method,body:JSON.stringify(body)});} catch(error) { if (error.status===409 && (error.detail?.confirm_conflicts || error.detail?.conflict_status) && !body.confirm_conflicts && confirm(error.message+'. Vil du gemme alligevel?')) { return api(path,{method,body:JSON.stringify({...body,confirm_conflicts:true})}); } throw error; } } function error(e) {$('deskError').textContent=e.message;$('deskError').hidden=false;} function status(message, action = null) { $('deskStatus').querySelector('span').textContent=message; undo=action;$('deskUndo').hidden=!action; } function select(id) { selected=Number(id)||null; root.querySelectorAll('[data-case-row]').forEach(row=>row.classList.toggle('is-selected',Number(row.dataset.caseRow)===selected)); root.querySelectorAll('[data-calendar-case]').forEach(el=>el.classList.toggle('desk-calendar-selected',Number(el.dataset.calendarCase)===selected)); const row=desk?.items.find(r=>r.sag_id===selected); $('deskSelection').textContent=row ? `#${row.sag_id} · ${row.priority_reason}. Resterende: ${fmt(row.remaining_work_minutes)}. Fremtidig allokering: ${fmt(row.future_planned_minutes)}.` : 'Vælg en opgave eller en aftale for at se sammenhængen.'; } function contact(row) { const person=row.contact; if(!person)return 'Ingen kontakt'; const phone=String(person.phone||'').replace(/[^+\d]/g,''); const email=String(person.email||''); return `${esc(person.name||'Kontakt')}`; } function attentionReasons(row, now) { const reasons=[]; if(row.deadline && new Date(row.deadline)0) reasons.push(`Mangler ${fmt(row.missing_minutes)} i planen`); if(row.blocked_reason) reasons.push(row.blocked_reason); if(row.booking_after_deadline) reasons.push('Tid reserveret efter deadline'); return reasons; } function renderPressure(risk, overload, now) { const pressure=$('deskPressure'); pressure.hidden=!risk.length&&!overload; if(pressure.hidden) { pressure.replaceChildren(); return; } const items=risk.map(row=>`
  • `).join(''); pressure.innerHTML=`${risk.length?`${risk.length} opgave${risk.length===1?'':'r'} kræver opmærksomhed`:''}${overload?`
    Kapacitet: Dagens arbejde overstiger den resterende ledige arbejdstid med ${esc(fmt(overload))}.
    `:''}`; } function render() { const query=$('deskSearch').value.toLocaleLowerCase(); const daily=desk.items.filter(r=>r.in_day); const rows=daily.filter(r=>[r.sag_id,r.titel,r.customer_name,r.contact?.name].join(' ').toLocaleLowerCase().includes(query)); const known=daily.filter(r=>r.remaining_work_minutes!=null); const unknown=daily.length-known.length; $('deskCount').textContent=String(daily.length); $('deskEstimate').textContent=fmt(known.reduce((n,r)=>n+r.remaining_work_minutes,0))+(unknown?` + ${unknown} uden estimat`:''); const dayStart=new Date(activeDay+'T00:00:00'),dayEnd=new Date(dayStart);dayEnd.setDate(dayEnd.getDate()+1); const totalBooked=(schedule.bookings||[]).reduce((n,r)=>n+Math.max(0,(Math.min(new Date(r.ends_at),dayEnd)-Math.max(new Date(r.starts_at),dayStart))/60000),0); $('deskAllocated').textContent=fmt(Math.round(totalBooked)); $('deskMissing').textContent=fmt(known.reduce((n,r)=>n+r.missing_minutes,0))+(unknown?' + ukendt':''); const now=new Date(); const risk=daily.filter(r=>r.deadline && new Date(r.deadline)0 || r.blocked_reason || r.booking_after_deadline)); const unplannedToday=known.filter(r=>!r.blocked_reason).reduce((n,r)=>n+Math.max(0,r.remaining_work_minutes-r.planned_today_minutes),0); const overload=desk.capacity?.configured?Math.max(0,unplannedToday-desk.capacity.free_minutes):0; renderPressure(risk,overload,now); $('deskOrderLabel').textContent=desk.manual_order?'Din gemte rækkefølge · træk for at ændre':'Anbefalet: deadline, faste tider, start og resterende arbejde'; $('deskRecommend').disabled=!desk.can_edit || !desk.manual_order; const incoming=desk.items.filter(r=>r.is_new && r.work_status!=='done'); $('deskInbox').hidden=!incoming.length; $('deskInboxCount').textContent=String(incoming.length); $('deskSeen').disabled=!desk.can_edit; $('deskInboxItems').innerHTML=incoming.map(r=>`#${r.sag_id} · ${esc(r.titel)}${!r.in_day?' · senere':''}`).join(''); $('deskTasks').innerHTML=rows.map(r=>{ const overdue=Boolean(r.deadline && new Date(r.deadline) ${overdue?'Deadline overskredet: ':''}${esc(r.titel)}#${r.sag_id} · ${esc(r.customer_name||'Intern sag')}${r.is_new?' · NY':''}${esc(r.priority_reason)}${r.active_timer?'● Timer aktiv':''} ${contact(r)} ${esc(dateLabel(r.deadline))}${r.deadline?' · '+clock(r.deadline):''}Start: ${esc(dateLabel(r.start_date))}${r.start_time?' '+esc(r.start_time.slice(0,5)):''}${r.today_slots.map(s=>`${clock(s.starts_at)}–${clock(s.ends_at)}`).join('')}
    Minutter · din delRest: ${esc(fmt(r.remaining_work_minutes))} ${esc(fmt(r.planned_today_minutes))}I dagFremtid: ${esc(fmt(r.future_planned_minutes))}${r.missing_minutes==null?'Estimat mangler':r.missing_minutes?'Mangler '+fmt(r.missing_minutes):'Tid er reserveret'}
    `; }).join('') || 'Ingen opgaver i denne visning. Se “Alle opgaver” for fremtidige opgaver.'; select(selected); } async function load() { const seq=++requestSeq; const result=await api(planner+'/desk?day='+activeDay); if(seq!==requestSeq)return; desk=result;$('deskError').hidden=true;render(); } async function refresh() {await load();calendar?.refetchEvents();await loadRecent();} function dialog(title, fields, submit, label='Gem') { $('deskDialogTitle').textContent=title;$('deskFields').innerHTML=fields;$('deskFormError').textContent='';$('deskSave').textContent=label; $('deskForm').onsubmit=async event=>{ event.preventDefault();if(busy)return;busy=true; const controls=[...$('deskForm').querySelectorAll('button,input,select,textarea')],disabled=controls.map(c=>c.disabled);controls.forEach(c=>c.disabled=true); try {await submit($('deskForm'));$('deskDialog').close();await refresh();} catch(e) {$('deskFormError').textContent=e.message;} finally {busy=false;controls.forEach((c,i)=>c.disabled=disabled[i]);} }; $('deskDialog').showModal(); } const field=(label,name,value='',type='text',extra='')=>``; const atDay=(clockValue='09:00')=>new Date(activeDay+'T'+clockValue); async function saveOrder(ids, mode='manual') { const before={ids:desk.stored_order_ids||desk.items.map(r=>r.sag_id),mode:desk.manual_order?'manual':'recommended'}; const response=await mutation(planner+'/desk/order','PUT',{ids,mode,version:desk.version}); status(mode==='manual'?'Din rækkefølge er gemt.':'Anbefalet rækkefølge er valgt.',async()=>{ await mutation(planner+'/desk/order','PUT',{...before,version:response.version}); }); await load(); } async function reorder(id,beforeId) { if(!desk.can_edit || id===beforeId)return; const ids=desk.items.map(r=>r.sag_id).filter(sid=>sid!==id); const index=beforeId?ids.indexOf(beforeId):ids.length; ids.splice(index<0?ids.length:index,0,id); await saveOrder(ids); } function eventBody(r,start,end) { return {title:r.title,description:r.description,sag_id:r.sag_id,kind:r.kind||'work',user_ids:r.participants?.length?r.participants:[uid], version:r.event_version,starts_at:start,ends_at:end}; } function canMove(r) {return !r.private && !r.locked && (r.participants?.length?r.participants:[uid]).every(id=>metadata.editable_user_ids.includes(id));} async function changeBooking(info) { const r=info.event.extendedProps.booking; if(!r || !canMove(r)){info.revert();return;} const oldStart=r.starts_at,oldEnd=r.ends_at; const path=planner+(r.event_id?'/events/'+r.event_id:'/allocations/'+r.id); const body=r.event_id?eventBody(r,info.event.start.toISOString(),info.event.end.toISOString()):{version:r.version,starts_at:info.event.start.toISOString(),ends_at:info.event.end.toISOString()}; try { const saved=await mutation(path,'PATCH',body); const version=r.event_id?saved.event.version:saved.version; status('Aftalen er opdateret.',async()=>{ await mutation(path,'PATCH',r.event_id?{...eventBody(r,oldStart,oldEnd),version}:{version,starts_at:oldStart,ends_at:oldEnd}); }); await refresh(); } catch(e) {info.revert();error(e);calendar.refetchEvents();} } function reservationDuration(row) { const minutes=row.missing_minutes>0?row.missing_minutes:row.remaining_work_minutes>0?row.remaining_work_minutes:metadata.settings?.default_allocation_minutes||60; return Math.max(15,Math.min(1440,Math.ceil(minutes/15)*15)); } async function saveReservation(row,start,end) { const result=await mutation(planner+'/events','POST',{title:row.titel,sag_id:row.sag_id,user_ids:[uid],kind:'work',starts_at:start.toISOString(),ends_at:end.toISOString()}); status(`Tid til #${row.sag_id} er reserveret kl. ${clock(start)}–${clock(end)}.`,async()=>api(planner+`/events/${result.event.id}?version=${result.event.version}`,{method:'DELETE'})); } async function dropReservation(row,start) { if(busy||!desk.can_edit||!row)return; busy=true; try {await saveReservation(row,start,new Date(start.getTime()+reservationDuration(row)*60000));await refresh();} catch(e){error(e);}finally{busy=false;} } function plan(row,start=atDay()) { const duration=reservationDuration(row); dialog('Planlæg tid · #'+row.sag_id,`

    ${esc(row.titel)}

    ${field('Fra','start',local(start),'datetime-local','required')}${field('Til','end',local(new Date(start.getTime()+duration*60000)),'datetime-local','required')}

    Reserverer tid til dig. Faktisk arbejde registreres separat.

    `,async form=>{ const from=new Date(form.elements.start.value),to=new Date(form.elements.end.value); if(to<=from)throw new Error('Slut skal ligge efter start'); await saveReservation(row,from,to); },'Reservér tid'); } function registerTime(sid=null,description='') { const row=desk.items.find(r=>r.sag_id===sid); const start=new Date(Date.now()-30*60000),requestId=crypto.randomUUID(); const options=desk.items.map(r=>``).join(''); let pending=null; dialog('Registrér faktisk arbejdstid',`${field('Hvad har du lavet?','description',description||row?.titel||'','text','required maxlength="2000"')}
    ${field('Start','start',local(start),'datetime-local','required')}${field('Minutter','minutes','30','number','min="1" max="1440" required')}

    Gemmes som faktisk arbejdstid til kontrol. Der oprettes ingen sag eller kalenderaftale.

    `,async form=>{ if(!pending) { const selectedCase=Number(form.elements.sag_id.value)||null; const body={request_id:requestId,description:form.elements.description.value,starts_at:new Date(form.elements.start.value).toISOString(),minutes:Number(form.elements.minutes.value)}; pending=selectedCase?{path:planner+'/desk/time',body:{...body,sag_id:selectedCase}}:{path:'/api/v1/timetracking/quick-time',body:{...body,mode:'registered',user_ids:[uid]}}; } try{await mutation(pending.path,'POST',pending.body);}catch(e){if(e.status && e.status<500){pending=null;}else{e.message+=' Tryk Gem igen for at genprøve samme registrering.';}throw e;} status('Tiden er registreret.'); },'Gem tid'); } function meetingPayload(form, requestId, minutes, customer, contact) { const users=[...form.querySelectorAll('[name=person]:checked')].map(el=>Number(el.value)); if(!users.length)throw new Error('Vælg mindst én medarbejder'); if(![15,30,45,60].includes(minutes))throw new Error('Vælg 15, 30, 45 eller 60 minutter'); return {request_id:requestId,mode:'registered',description:form.elements.description.value.trim()||'Internt møde', minutes,starts_at:new Date(Date.now()-minutes*60000).toISOString(),user_ids:users,customer_id:customer,contact_id:contact}; } async function meeting() { const options=await api('/api/v1/timetracking/quick-time/options'); const people=options.users.filter(u=>u.can_register); let duration=30,customer=null,contact=null,pending=null; const requestId=crypto.randomUUID(); dialog('Registrér mødetid',`
    Hvor længe?
    ${[15,30,45,60].map(n=>``).join('')}
    Medarbejdere${people.map(u=>``).join('')}
    ${field('Beskrivelse · valgfrit','description','Internt møde','text','maxlength="2000"')}
    Kunde og kontakt · valgfrit${field('Kunde','customer','','search','autocomplete="off" placeholder="Søg kunde…"')}
    ${field('Kontaktperson','contact','','search','autocomplete="off" placeholder="Søg kontakt…"')}

    Registrerer afholdt tid, som slutter nu. Hver valgt medarbejder får det valgte antal minutter. Uden kunde gemmes tiden som internt arbejde hos jer.

    `,async form=>{ if(!pending){ if(form.elements.customer.value.trim()&&!customer || form.elements.contact.value.trim()&&!contact)throw new Error('Vælg kunde og kontakt fra søgeresultaterne, eller ryd felterne'); pending=meetingPayload(form,requestId,duration,customer,contact); } try{await mutation('/api/v1/timetracking/quick-time','POST',pending);}catch(e){if(e.status&&e.status<500)pending=null;else e.message+=' Tryk Gem igen for at genprøve samme registrering.';throw e;} status('Mødetiden er registreret for de valgte medarbejdere.'); },'Gem mødetid'); const form=$('deskForm'); form.querySelector('[data-meeting-durations]').onclick=event=>{ const button=event.target.closest('[data-duration]');if(!button)return; duration=Number(button.dataset.duration); form.querySelectorAll('[data-duration]').forEach(b=>{const active=Number(b.dataset.duration)===duration;b.classList.toggle('btn-primary',active);b.classList.toggle('btn-outline-primary',!active);b.setAttribute('aria-pressed',String(active));}); }; function search(name,type){ const input=form.elements[name],list=form.querySelector(type==='customers'?'[data-meeting-customers]':'[data-meeting-contacts]'); let generation=0,timer; input.addEventListener('input',()=>{ const current=++generation; if(type==='customers'){customer=null;contact=null;form.elements.contact.value='';form.querySelector('[data-meeting-contacts]').replaceChildren();}else contact=null; clearTimeout(timer);list.replaceChildren();const term=input.value.trim();if(term.length<2)return; timer=setTimeout(async()=>{ try{ const selectedCustomer=customer; const data=await api(type==='contacts'&&selectedCustomer?`/api/v1/customers/${selectedCustomer}/contacts`:`/api/v1/search/${type}?q=${encodeURIComponent(term)}`); if(current!==generation||!$('deskDialog').open||form.elements[name]!==input||type==='contacts'&&selectedCustomer!==customer)return; let rows=Array.isArray(data)?data:data.results||data.items||[]; const label=r=>r.name||r.contact_name||[r.first_name,r.last_name].filter(Boolean).join(' ')||r.label||''; if(type==='contacts'&&selectedCustomer)rows=rows.filter(r=>label(r).toLocaleLowerCase().includes(term.toLocaleLowerCase())); list.replaceChildren(); for(const r of rows.slice(0,10)){ const button=document.createElement('button');button.type='button';button.className='list-group-item list-group-item-action';button.textContent=label(r); button.onclick=()=>{if(type==='customers'){customer=Number(r.id);contact=null;form.elements.contact.value='';form.querySelector('[data-meeting-contacts]').replaceChildren();}else contact=Number(r.id);input.value=label(r);list.replaceChildren();};list.append(button); } }catch(e){$('deskFormError').textContent=e.message;} },200); }); } search('customer','customers');search('contact','contacts'); } async function registerMeeting(id) { const details=await api(planner+`/desk/events/${id}/attendance`),event=details.event; dialog('Registrér afholdt mødetid',`

    ${esc(event.title)}

    Vælg de faktiske deltagere. Start og slut kan rettes for hver person. Allerede registreret tid kan ikke oprettes igen.

    ${details.participants.map(p=>`
    ${esc(p.name)}${p.registered?' · registreret':''}
    ${field('Deltog fra','from_'+p.user_id,local(new Date(event.starts_at)),'datetime-local',p.registered||!p.can_register?'disabled':'required')}${field('Deltog til','to_'+p.user_id,local(new Date(event.ends_at)),'datetime-local',p.registered||!p.can_register?'disabled':'required')}
    `).join('')}`,async form=>{ const attendees=[...form.querySelectorAll('[data-attendee]')].filter(el=>el.querySelector('[name=attend]').checked).map(el=>({user_id:Number(el.dataset.attendee),starts_at:new Date(el.querySelector('[name^=from_]').value).toISOString(),ends_at:new Date(el.querySelector('[name^=to_]').value).toISOString()})); if(!attendees.length)throw new Error('Vælg mindst én deltager, som ikke allerede er registreret'); await mutation(planner+`/desk/events/${id}/register`,'POST',{version:event.version,attendees});status('Afholdt mødetid er registreret.'); },'Registrér mødetid'); } async function startTimer(sid=null,description='') { if(busy)return; busy=true; try {await mutation(planner+'/desk/timer','POST',{sag_id:sid,description:description||desk.items.find(r=>r.sag_id===sid)?.titel||'Internt arbejde'});status('Timeren er startet.');await refresh();window.dispatchEvent(new CustomEvent('timer-state-changed'));} finally {busy=false;} } async function loadRecent() { try{recent=(await api(planner+'/desk/recent')).entries;$('deskContinue').disabled=!recent.length;}catch(e){$('deskContinue').disabled=true;} } function continueWork() { const active=recent.find(r=>r.aktiv_timer&&!r.slut_tid); if(active){status('Du har allerede en aktiv timer. Pause eller stop den i bundlinjen.');return;} const paused=recent.find(r=>r.paused_at&&!r.slut_tid),last=paused||recent[0]; if(!last)return; dialog('Fortsæt sidste arbejde',`

    ${esc(last.description||last.titel||'Internt arbejde')}

    ${last.sag_id?'Sag #'+last.sag_id:'Uden sag'} · ${paused?'Genoptager samme pausede timer.':'Starter en ny timer med samme beskrivelse.'}

    `,async()=>{ if(paused)await mutation('/api/v1/timetracking/time/resume','POST',{time_id:last.id}); else await mutation(planner+'/desk/timer','POST',{sag_id:last.sag_id,description:last.description||last.titel||'Internt arbejde'}); status('Arbejdet er genoptaget.');window.dispatchEvent(new CustomEvent('timer-state-changed')); },'Fortsæt arbejdet'); } function renderMeetings() { const groups=new Map(); (schedule.bookings||[]).filter(r=>['meeting','internal'].includes(r.kind)&&!r.sag_id&&r.event_id).forEach(r=>groups.set(r.event_id,r)); $('deskMeetings').innerHTML=[...groups.values()].map(r=>`
    ${esc(r.title)}${clock(r.starts_at)}–${clock(r.ends_at)}
    `).join(''); } function calendarColumns(events) { for(const column of [0,1]) { const rows=events.filter(e=>(e.extendedProps?.deskColumn||0)===column).sort((a,b)=>new Date(a.start)-new Date(b.start)); let cluster=[],ends=[],until=0; function finish(){ for(const event of cluster){const width=50/ends.length,left=column*50+event.extendedProps.deskLane*width;event.extendedProps.deskLeft=left;event.extendedProps.deskRight=100-left-width;if(column===0)event.classNames=[...new Set([...(event.classNames||[]),'desk-allocated'])];} cluster=[];ends=[];until=0; } for(const event of rows){ const start=new Date(event.start).getTime(),end=new Date(event.end).getTime(); if(start>=until)finish(); let lane=ends.findIndex(value=>value<=start);if(lane<0)lane=ends.length; ends[lane]=end;until=Math.max(until,end); event.extendedProps={...event.extendedProps,deskLane:lane};cluster.push(event); } finish(); } return events; } function workingWindow(day) { const weekday=(new Date(day+'T12:00:00').getDay()+6)%7; const rows=workHours.filter(r=>r.weekday===weekday); return {start:rows.length?rows.map(r=>r.start_time).sort()[0].slice(0,5):'07:00',end:rows.length?rows.map(r=>r.end_time).sort().at(-1).slice(0,5):'17:00'}; } async function events(info,success,failure) { try { const data=await api(planner+`/schedule?starts_at=${encodeURIComponent(info.startStr)}&ends_at=${encodeURIComponent(info.endStr)}&user_id=${uid}`); if(dayKey(info.start)!==activeDay){success([]);return;} schedule=data; const grouped=new Map(); (data.bookings||[]).forEach(r=>grouped.set(r.event_id?'event-'+r.event_id:'allocation-'+r.id,r)); const hours=workingWindow(activeDay); let min=Number(hours.start.slice(0,2)),max=Math.ceil(Number(hours.end.slice(0,2))+Number(hours.end.slice(3))/60); const bookings=[...grouped.values()].map(r=>{ const start=new Date(r.starts_at),end=new Date(r.ends_at); min=Math.min(min,dayKey(start)activeDay?24:end.getHours()+(end.getMinutes()>0?1:0)); return {id:r.event_id?'event-'+r.event_id:'allocation-'+r.id,title:(r.has_conflict?'⚠ ':'')+r.title,start:r.starts_at,end:r.ends_at,editable:canMove(r),extendedProps:{booking:r,sagId:r.sag_id,deskColumn:!r.kind||r.kind==='work'?0:1}}; }); const extra=(data.absences||[]).map(r=>({id:'absence-'+r.id,title:r.kind_label||'Fravær',start:r.starts_at,end:r.ends_at,editable:false,extendedProps:{deskColumn:1}})); (data.standalone_plans||[]).forEach(r=>extra.push({id:'quick-'+r.id,title:r.title,start:r.starts_at,end:r.ends_at,editable:false})); (data.registered||[]).forEach(r=>extra.push({id:'actual-'+r.id,title:'◷ '+r.title,start:r.starts_at,end:r.ends_at,editable:false,classNames:['desk-actual'],extendedProps:{sagId:r.sag_id,deskColumn:1}})); (data.external_busy||[]).forEach(r=>extra.push({id:'external-'+r.id,title:'Optaget eksternt',start:r.starts_at,end:r.ends_at,editable:false,extendedProps:{deskColumn:1}})); extra.forEach(r=>{const start=new Date(r.start),end=new Date(r.end);min=Math.min(min,dayKey(start)activeDay?24:end.getHours()+(end.getMinutes()>0?1:0));}); const from=String(min).padStart(2,'0')+':00:00',to=String(max).padStart(2,'0')+':00:00'; const rangeChanged=calendar.getOption('slotMinTime')!==from||calendar.getOption('slotMaxTime')!==to; if(calendar.getOption('slotMinTime')!==from)calendar.setOption('slotMinTime',from); if(calendar.getOption('slotMaxTime')!==to)calendar.setOption('slotMaxTime',to); const scroll=hours.start+':00'; if(calendar.getOption('scrollTime')!==scroll)calendar.setOption('scrollTime',scroll); $('deskWorkHours').textContent=`Arbejdstid ${hours.start}–${hours.end}${minNumber(hours.end.slice(0,2))?' · rul for at se tider udenfor':''}`; success(calendarColumns([...bookings,...extra])); if(calendarScrolledDay!==activeDay||rangeChanged){calendarScrolledDay=activeDay;requestAnimationFrame(()=>calendar.scrollToTime(scroll));} if(desk)render();renderMeetings(); }catch(e){failure(e);error(e);} } $('deskTasks').addEventListener('click',event=>{ const row=event.target.closest('[data-case-row]');if(row)select(row.dataset.caseRow); const planButton=event.target.closest('[data-plan]'),start=event.target.closest('[data-start]'),timeButton=event.target.closest('[data-time]'),up=event.target.closest('[data-up]'),down=event.target.closest('[data-down]'); if(planButton)plan(desk.items.find(r=>r.sag_id===Number(planButton.dataset.plan))); if(start)startTimer(Number(start.dataset.start)).catch(error); if(timeButton)registerTime(Number(timeButton.dataset.time)); if(up||down){const id=Number(up?.dataset.up||down.dataset.down),visible=desk.items.filter(r=>r.in_day),index=visible.findIndex(r=>r.sag_id===id);if(up&&index>0)reorder(id,visible[index-1].sag_id).catch(error);if(down&&index{ const button=event.target.closest('[data-attention-case]'); if(!button)return; const caseId=Number(button.dataset.attentionCase); if($('deskSearch').value) { $('deskSearch').value=''; render(); } select(caseId); const row=root.querySelector(`[data-case-row="${caseId}"]`); row?.scrollIntoView({behavior:'smooth',block:'center'}); row?.querySelector('.desk-case')?.focus({preventScroll:true}); }); $('deskTasks').addEventListener('submit',async event=>{ const form=event.target.closest('[data-estimate]');if(!form)return;event.preventDefault();if(busy)return;busy=true; const row=desk.items.find(r=>r.sag_id===Number(form.dataset.estimate)),old=row.estimated_minutes,value=form.elements.minutes.value; try { const saved=await mutation(planner+`/queue/${uid}/${row.sag_id}`,'PATCH',{version:desk.version,estimated_minutes:value===''?null:Number(value)}); status('Estimatet er gemt.',async()=>mutation(planner+`/queue/${uid}/${row.sag_id}`,'PATCH',{version:saved.version,estimated_minutes:old})); await load(); }catch(e){error(e);await load().catch(()=>{});}finally{busy=false;} }); root.addEventListener('dragstart',event=>{ const grip=event.target.closest('[data-reorder]'),slot=event.target.closest('[data-plan]');if(!grip&&!slot)return; if(!desk.can_edit || busy){event.preventDefault();return;} drag={id:Number(grip?.dataset.reorder||slot.dataset.plan),kind:grip?'order':'slot'}; event.dataTransfer.setData('application/x-bmc-desk',String(drag.id));event.dataTransfer.effectAllowed='move'; }); root.addEventListener('dragend',()=>{drag=null;root.querySelectorAll('.is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));}); $('deskTasks').addEventListener('dragover',event=>{if(drag?.kind==='order'){event.preventDefault();root.querySelectorAll('.is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));event.target.closest('tr')?.classList.add('is-drag-over');}}); $('deskTasks').addEventListener('drop',async event=>{ if(drag?.kind!=='order')return;event.preventDefault();const id=drag.id;drag=null; try{await reorder(id,Number(event.target.closest('[data-case-row]')?.dataset.caseRow)||null);}catch(e){error(e);await load().catch(()=>{});} }); $('deskCalendar').addEventListener('dragover',event=>{if(drag?.kind==='slot')event.preventDefault();}); $('deskCalendar').addEventListener('drop',event=>{ if(drag?.kind!=='slot')return;event.preventDefault(); const row=desk.items.find(r=>r.sag_id===drag.id);drag=null; const lane=[...$('deskCalendar').querySelectorAll('.fc-timegrid-slot-lane[data-time]')].find(el=>{const rect=el.getBoundingClientRect();return event.clientY>=rect.top && event.clientY{if(!undo||busy)return;busy=true;const action=undo;try{await action();status('Ændringen er fortrudt.');await refresh();}catch(e){error(e);status('Kunne ikke fortryde. Planen kan være ændret siden.');}finally{busy=false;}}; $('deskRecommend').onclick=()=>saveOrder([],'recommended').catch(error); $('deskSeen').onclick=async()=>{try{await mutation(planner+'/desk/seen','POST',{});await load();}catch(e){error(e);}}; $('deskTime').onclick=()=>registerTime();$('deskMeeting').onclick=()=>meeting().catch(error);$('deskContinue').onclick=continueWork; $('deskSearch').oninput=()=>desk&&render();$('deskClose').onclick=()=>$('deskDialog').close(); $('deskRefresh').onclick=()=>refresh().catch(error); $('deskMeetings').onclick=event=>{const button=event.target.closest('[data-meeting-time]');if(button)registerMeeting(Number(button.dataset.meetingTime)).catch(error);}; async function init() { [metadata,workHours]=await Promise.all([api(planner+'/metadata'),api(planner+'/work-schedules/'+uid)]); $('deskMeeting').disabled=false; calendar=new FullCalendar.Calendar($('deskCalendar'),{ locale:'da',initialView:'timeGridDay',firstDay:1,allDaySlot:false,height:720,scrollTime:workingWindow(activeDay).start+':00',slotMinTime:'07:00:00',slotMaxTime:'17:00:00',slotDuration:'00:15:00',slotLabelInterval:'01:00',snapDuration:'00:15:00',nowIndicator:true,editable:true,eventDurationEditable:true, headerToolbar:{left:'prev,next today',center:'title',right:''},buttonText:{today:'I dag'},titleFormat:{weekday:'short',day:'numeric',month:'short'}, dayHeaderContent:()=>({html:'
    AllokeretAftaler / registreret
    '}), events,eventDrop:changeBooking,eventResize:changeBooking, eventDidMount:info=>{const props=info.event.extendedProps;if(props.deskLeft!=null){requestAnimationFrame(()=>{const harness=info.el.closest('.fc-timegrid-event-harness');if(harness){harness.classList.add('desk-column-event');harness.style.setProperty('--desk-event-left',props.deskLeft+'%');harness.style.setProperty('--desk-event-right',props.deskRight+'%');}});}if(info.event.extendedProps.sagId)info.el.dataset.calendarCase=info.event.extendedProps.sagId;select(selected);}, eventClick:info=>{const r=info.event.extendedProps.booking;select(info.event.extendedProps.sagId);if(r&&['meeting','internal'].includes(r.kind)&&!r.sag_id)registerMeeting(r.event_id).catch(error);}, datesSet:info=>{const day=dayKey(info.start);if(day!==activeDay){activeDay=day;load().catch(error);}$('deskDate').textContent=info.start.toLocaleDateString('da-DK',{weekday:'long',day:'numeric',month:'long',year:'numeric'});}, dateClick:info=>{dialog('Reservér tid til en opgave',``,async form=>{const row=desk.items.find(r=>r.sag_id===Number(form.elements.case.value));if(!row)throw new Error('Vælg en opgave');$('deskDialog').close();setTimeout(()=>plan(row,info.date),0);},'Vælg opgave');}, }); await load();calendar.render();await loadRecent(); setInterval(()=>{if(document.hidden||busy||drag||$('deskDialog').open||root.contains(document.activeElement)&&document.activeElement.matches('input,select,textarea'))return;refresh().catch(error);},30000); } init().catch(error); })();