bmc_hub/static/js/planning-work.js

413 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 `<div class="work-task-values mt-2">${buttons.map(([kind,label,icon,value,allowed])=>`<button type="button" class="work-value-button" data-task-kind="${kind}" data-control-case="${id}" draggable="${allowed}" ${allowed?'':'disabled'} title="Træk ${label.toLowerCase()} til kalenderen, eller klik for at vælge"><span class="work-value-label"><span class="work-value-icon"><i class="bi ${icon}" aria-hidden="true"></i></span><span>${label}</span><i class="bi bi-grip-vertical work-value-grip" aria-hidden="true"></i></span><span class="work-value-content">${esc(value)}</span></button>`).join('')}</div>${slots.length?`<div class="work-slot-values mt-2"><span class="work-muted">Kommende tider · ${esc(metadata.users.find(u=>u.id===target)?.name||'')}</span><ul class="list-unstyled mb-0 mt-1">${slots.map(slot=>`<li><i class="bi bi-clock me-1" aria-hidden="true"></i>${esc(new Intl.DateTimeFormat('da-DK',{dateStyle:'short',timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(slot.starts_at)))} – ${esc(new Intl.DateTimeFormat('da-DK',{dateStyle:caseDate(slot.starts_at)!==caseDate(slot.ends_at)?'short':undefined,timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(slot.ends_at)))}</li>`).join('')}</ul></div>`:''}`;
}
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)<new Date();
return r.work_status===filter||(filter==='working'&&r.active_timer);
});
byId('workQueueHeading').textContent=target===uid?'Min arbejdskø':'Kø · '+(metadata.users.find(u=>u.id===target)?.name||'Medarbejder');
byId('workQueue').innerHTML=rows.map(r=>`<article class="card work-card" data-task-id="${r.sag_id}" data-task-source="queue" data-sag="${r.sag_id}" draggable="${metadata.editable_user_ids.includes(target)}"><div class="work-row"><a href="/sag/${r.sag_id}"><i class="bi bi-grip-vertical me-1" aria-hidden="true"></i>${r.pinned?'📌 ':''}#${r.sag_id} ${esc(r.titel)}</a></div><div class="work-muted mt-1">${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':''}</div>${taskControls(r)}<div class="work-actions mt-2"><button class="btn btn-sm btn-outline-secondary" data-action="pause" data-id="${r.sag_id}">${r.work_status==='paused'?'Fortsæt':'Pause'}</button></div><details class="mt-2"><summary class="small text-secondary">Flere handlinger</summary><div class="work-actions mt-2"><button class="btn btn-sm btn-outline-secondary" data-action="up" data-id="${r.sag_id}" aria-label="Flyt op">↑</button><button class="btn btn-sm btn-outline-secondary" data-action="down" data-id="${r.sag_id}" aria-label="Flyt ned">↓</button><button class="btn btn-sm btn-outline-secondary" data-action="pin" data-id="${r.sag_id}">${r.pinned?'Frigør':'Fastlås'}</button><button class="btn btn-sm btn-outline-secondary" data-action="transfer" data-id="${r.sag_id}">Vælg anden medarbejder</button><button class="btn btn-sm btn-outline-secondary" data-action="details" data-id="${r.sag_id}">Estimat og sagsplan</button><button class="btn btn-sm btn-outline-secondary" data-action="done" data-id="${r.sag_id}">${r.work_status==='done'?'Genåbn arbejde':'Arbejdet færdigt'}</button><button class="btn btn-sm btn-outline-secondary" data-action="defer" data-id="${r.sag_id}">Udsæt</button><button class="btn btn-sm btn-outline-secondary" data-action="unassign" data-id="${r.sag_id}">Fjern fra køen</button></div></details></article>`).join('')||'<p class="work-muted">Ingen opgaver i denne visning.</p>';
}
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<limit;renderQueue();
}
async function change(id,values) {
try {await api(`/queue/${target}/${id}`,{method:'PATCH',body:JSON.stringify({version:queue.version,...values})}); await loadQueue();calendar.refetchEvents();}
catch(e){error(e);await loadQueue();}
}
byId('workQueue').addEventListener('click',async event => {
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<group.length-1)return change(id,{before_sag_id:group[index+2]?.sag_id??null});
});
byId('workQueue').addEventListener('dragover',e=>{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=>`<option value="${r.id}">${esc(r.title)} · ${esc(r.customer_name)}</option>`).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=`<form><h2 class="h5">Overdrag arbejde · #${row.sag_id}</h2><p>Den ansvarlige på sagen bevares.</p><label>Medarbejder<select name="employee" class="form-select" required>${metadata.users.filter(u=>u.id!==target&&metadata.editable_user_ids.includes(u.id)).map(u=>`<option value="${u.id}">${esc(u.name)}</option>`).join('')}</select></label><p class="text-danger" role="alert"></p><button class="btn btn-primary">Overdrag</button> <button type="button" class="btn btn-light" data-close>Luk</button></form>`;
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=`<h2 class="h5">Planlægningsindstillinger</h2><form data-general><label>Advarsel før aftale (minutter)<input class="form-control" name="warning" type="number" min="0" max="1440" value="${config.appointment_warning_minutes}"></label><label>Deadlinepåmindelse (dage før, 0 = slået fra)<input class="form-control" name="deadline_days" type="number" min="0" max="30" value="${config.deadline_warning_days??1}"></label><button class="btn btn-primary">Gem generelt</button><p role="status"></p></form><hr><h3 class="h6">Arbejdstid · ${esc(metadata.users.find(u=>u.id===target)?.name)}</h3><form data-schedule>${labels.map((label,d)=>{const row=schedule.find(r=>r.weekday===d);return `<div class="row g-1 align-items-center mt-1"><label class="col-4"><input name="active_${d}" type="checkbox" ${row?'checked':''}> ${label}</label><div class="col-3"><input class="form-control form-control-sm" type="time" name="start_${d}" value="${row?.start_time||'08:00'}" aria-label="${label} start"></div><div class="col-3"><input class="form-control form-control-sm" type="time" name="end_${d}" value="${row?.end_time||'16:00'}" aria-label="${label} slut"></div><div class="col-2"><input class="form-control form-control-sm" type="number" min="0" name="break_${d}" value="${row?.break_minutes??30}" aria-label="${label} pause i minutter"></div></div>`;}).join('')}<p class="work-muted mt-2">Sidste kolonne er pause i minutter.</p><button class="btn btn-primary">Gem arbejdstid</button><p role="status"></p></form><hr><h3 class="h6">Aktivitets- og fraværstyper</h3><form data-type><label>Type<select name="code" class="form-select"><option value="">Ny type</option></select></label><label>Navn<input name="label" class="form-control" required maxlength="80"></label><label><input name="active" type="checkbox" checked> Aktiv</label><label><input name="absence" type="checkbox" checked> Fravær</label><button class="btn btn-primary">Gem type</button><p role="status"></p></form><button class="btn btn-light mt-3" data-close>Luk</button>`;
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=>`<option value="${esc(t.code)}">${esc(t.label)}</option>`).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=>`<option value="${esc(t.code)}">${esc(t.label)}</option>`).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=`<hr><h3 class="h6">Adgang til kollegers planlægning</h3><p class="work-muted">Gælder ${esc(metadata.users.find(u=>u.id===target)?.name)}. Medarbejderens rolle afgør, om der kan ses eller redigeres.</p><select name="employees" class="form-select" multiple size="6">${metadata.users.filter(u=>u.id!==target).map(u=>`<option value="${u.id}" ${scope.user_ids.includes(u.id)?'selected':''}>${esc(u.name)}</option>`).join('')}</select><button class="btn btn-primary mt-2">Gem adgang</button><p role="status"></p>`;
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=`<form><h2 class="h5">Sagsplan · #${id}</h2><p>${esc(row.titel)}</p><label>Sagsstart<input class="form-control" type="date" name="start_date" value="${esc(row.start_date||'')}"></label><label>Startklokkeslæt (valgfrit)<input class="form-control" type="time" name="start_time" value="${esc(row.start_time||'')}"></label><label>Intern deadline<input class="form-control" type="datetime-local" name="deadline" value="${dateTime(row.deadline)}"></label><label>Samlet estimat i minutter<input class="form-control" type="number" name="estimated_minutes" min="0" value="${row.estimated_minutes??''}"></label><h3 class="h6 mt-3">Fordeling mellem medarbejdere</h3>${row.assignees.map(a=>`<fieldset class="card work-card"><legend class="h6">${esc(a.name)}</legend><label>Estimat (minutter)<input type="number" min="0" name="estimate_${a.user_id}" class="form-control" value="${a.estimated_minutes??''}" ${metadata.editable_user_ids.includes(a.user_id)?'':'disabled'}></label><label>Manuelt resterende arbejde (valgfrit)<input type="number" min="0" name="remaining_${a.user_id}" class="form-control" value="${a.remaining_minutes??''}" ${metadata.editable_user_ids.includes(a.user_id)?'':'disabled'}></label></fieldset>`).join('')}<p class="work-muted">Tomt resterende arbejde beregnes fra individuelt estimat og registreret tid. Sagens samlede estimat fordeles ikke automatisk.</p>${metadata.permissions.some(p=>['planner.admin','planner.allocate'].includes(p))?`<label>Tilføj medarbejder<select name="new_user" class="form-select"><option value="">Ingen ny medarbejder</option>${metadata.users.filter(u=>metadata.editable_user_ids.includes(u.id)&&!row.assignees.some(a=>a.user_id===u.id)).map(u=>`<option value="${u.id}">${esc(u.name)}</option>`).join('')}</select></label><label>Estimat til ny medarbejder (minutter)<input type="number" min="0" name="new_minutes" class="form-control" disabled></label>`:''}<p class="text-danger" role="alert"></p><button class="btn btn-primary">Gem sagsplan</button> <button type="button" class="btn btn-light" data-close>Luk</button></form>`;
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='<strong class="small">Ferie og fravær · vist kalenderperiode</strong>'+(rows.length?'<div class="d-flex flex-wrap gap-2 mt-2">'+rows.map(r=>`<button type="button" class="btn btn-sm btn-outline-secondary" data-absence-id="${r.id}"><i class="bi bi-calendar-minus me-1" aria-hidden="true"></i>${esc(absenceEvent(r).title)} · ${esc(caseDate(r.starts_at))} – ${esc(caseDate(new Date(new Date(r.ends_at).getTime()-(r.whole_days?1:0)).toISOString()))}</button>`).join('')+'</div>':'<span class="small text-secondary mt-1">Intet planlagt fravær i den viste periode. Vælg uge eller måned for et større overblik.</span>');
}
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=>`<option value="${u.id}" ${u.id===selected?'selected':''} ${row||canRegister(u.id)?'':'disabled'}>${esc(u.name)}</option>`).join('');
form.elements.kind.innerHTML=reasonVisible?activityTypes.filter(t=>t.is_absence&&(t.active||t.code===row?.kind)).map(t=>`<option value="${esc(t.code)}" ${t.code===row?.kind?'selected':''}>${esc(t.label)}</option>`).join(''):'<option>Ikke tilgængelig</option>';
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])=>`<a class="btn btn-outline-primary" href="${esc(url)}">${esc(label)}</a>`).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)=>`<details class="work-help"><summary aria-label="${esc(label)}"><i class="bi bi-info-circle" aria-hidden="true"></i></summary><div>${esc(text)}</div></details>`;
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?`<div class="table-responsive"><table class="table table-hover unassigned-table mb-0"><thead><tr><th scope="col">Sag / kunde og planlægning</th><th scope="col">Sagens estimat</th><th scope="col"><span class="visually-hidden">Handling</span></th></tr></thead><tbody>${data.items.map(row=>{
const deadline=row.deadline?new Date(row.deadline):null;
const overdue=deadline&&deadline<new Date();
const priority=({urgent:'Haster',high:'Høj prioritet'})[row.priority];
return `<tr data-task-id="${row.id}" data-task-source="inbox" draggable="true"><td><span class="work-drag-hint me-1" aria-hidden="true"><i class="bi bi-grip-vertical"></i></span><a class="case-name" href="/sag/${row.id}"><span class="case-number">#${row.id}</span>${esc(row.titel)}</a>${priority?` <span class="badge ${row.priority==='urgent'?'text-bg-danger':'text-bg-warning'}">${priority}</span>`:''}<div class="work-muted mt-1">${esc(row.customer_name||'Ingen kunde')} · Ansvarlig: ${esc(row.responsible_name||'Ikke valgt')}</div>${taskControls(row)}</td><td class="work-legacy-deadline" data-label="Deadline"><span class="${overdue?'text-danger fw-semibold':''}">${deadline?esc(deadline.toLocaleDateString('da-DK',{timeZone:'Europe/Copenhagen'})):'Ingen deadline'}</span>${overdue?'<div class="small text-danger">Overskredet</div>':''}</td><td data-label="Sagens estimat">${row.estimated_minutes==null?'<span class="text-secondary">Ikke angivet</span>':esc(row.estimated_minutes)+' min'}</td><td class="text-md-end"><button type="button" class="btn btn-sm btn-primary" data-assign-case="${row.id}" aria-label="Vælg medarbejder til sag ${row.id}"><i class="bi bi-person-plus me-1" aria-hidden="true"></i>Vælg medarbejder</button></td></tr>`;
}).join('')}</tbody></table></div>`:`<div class="unassigned-empty"><i class="bi ${query?'bi-search':'bi-check-circle'} d-block mb-2" aria-hidden="true"></i><h3 class="h6">${query?'Ingen sager matcher din søgning':'Alle åbne sager er fordelt'}</h3><p class="mb-0">${query?'Prøv et andet sagsnummer, navn eller kundenavn.':'Sager uden en medarbejder vises her, når de bliver oprettet.'}</p></div>`;
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=`<form><h2 id="assignCaseHeading" class="h5">Tilføj sag til en medarbejders kø</h2><p class="fw-semibold mb-1">#${row.id} ${esc(row.titel)}</p><p class="work-muted">${esc(row.customer_name||'Ingen kunde')}</p><label for="assignEmployee">Hvem skal udføre arbejdet? <span class="text-danger" aria-hidden="true">*</span></label><select id="assignEmployee" name="employee" class="form-select" required><option value="">Vælg medarbejder…</option>${employees.map(u=>`<option value="${u.id}">${esc(u.name)}</option>`).join('')}</select><div class="d-flex align-items-center gap-1 mt-3"><label for="assignMinutes" class="mb-0">Forventet arbejdstid <span class="work-muted">(valgfrit)</span></label>${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')}</div><div class="input-group mt-1"><input id="assignMinutes" name="minutes" type="number" min="0" max="525600" step="1" class="form-control" placeholder="Fx 60"><span class="input-group-text">minutter</span></div><div class="small text-secondary mt-3"><i class="bi bi-info-circle me-1" aria-hidden="true"></i>Sagen tilføjes nederst i medarbejderens kø. Den ansvarlige på sagen ændres ikke.</div><details class="mt-2 small"><summary>Skal arbejdet også lægges i kalenderen?</summary><p class="text-secondary mt-2 mb-0">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.</p></details><p class="text-danger mt-3" role="alert"></p><div class="work-actions justify-content-end mt-3"><button type="button" class="btn btn-outline-secondary" data-close>Annullér</button><button type="submit" class="btn btn-primary" ${employees.length?'':'disabled'}>Tilføj til kø</button></div>${employees.length?'':'<p class="text-secondary mt-2">Du har ikke adgang til at tildele arbejde til en medarbejder.</p>'}</form>`;
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=`<h2 class="h5">Overdrag sag #${id}</h2><p>Sagen har <strong>${detail.booking_count} kommende reservationer</strong>. Hvad skal der ske med dem?</p><div class="d-grid gap-2"><button type="button" class="btn btn-primary" data-action="transfer">Overdrag tiderne til ${esc(employee)}</button><p class="small text-muted">Tidspunkterne bevares i den nye medarbejders kalender.</p><button type="button" class="btn btn-outline-danger" data-action="remove">Fjern de reserverede tider</button><p class="small text-muted">Den tidligere medarbejders tider frigives. Andre deltagere og registreret tid bevares.</p><button type="button" class="btn btn-light" data-action="cancel">Annullér overdragelse</button></div>`;
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=`<form><h2 class="h5">${kind==='start'?'Sæt start':kind==='deadline'?'Sæt deadline':'Start og deadline'}</h2><p class="fw-semibold">#${id} ${esc(plan.titel)}</p><label ${kind==='deadline'?'hidden':''}>Startdato<input class="form-control" name="start" type="date" value="${esc(plan.start_date||'')}"></label><label ${kind==='start'?'hidden':''}>Deadline<input class="form-control" name="deadline" type="date" value="${caseDate(plan.deadline)}"></label><p class="work-muted">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.</p><p role="alert" class="text-danger"></p><div class="work-actions justify-content-end"><button type="button" class="btn btn-outline-secondary" data-close>Luk</button><button class="btn btn-primary" ${canEditDates()?'':'disabled'}>Gem datoer</button></div></form>`;
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=`<form><h2 class="h5" id="slotHeading">Allokér et nyt tidsrum</h2><p class="fw-semibold">#${task.id} ${esc(task.title)}</p><label>Medarbejder<select name="employee" class="form-select" required>${employees.map(u=>`<option value="${u.id}" ${u.id===target?'selected':''}>${esc(u.name)}</option>`).join('')}</select></label><label>Fra<input type="datetime-local" name="start" class="form-control" value="${local(start)}" required></label><label>Til<input type="datetime-local" name="end" class="form-control" value="${local(end)}" required></label><p class="work-muted">Tiden reserveres til opgaven. Den faktiske arbejdstid registrerer du bagefter med timeren eller manuelt. Eksisterende reservationer bevares, og du kan tilføje flere tidsrum.</p><p role="alert" class="text-danger"></p><div class="work-actions justify-content-end"><button type="button" class="btn btn-outline-secondary" data-close>Annullér</button><button class="btn btn-primary" ${employees.length?'':'disabled'}>Tilføj tidsrum</button></div></form>`;
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.right&&y>=rect.top&&y<rect.bottom;
});
}
const day=cell?.dataset.date||(['timeGridDay','listDay'].includes(calendar.view.type)?caseDate(calendar.getDate()):null);
let clock=null;
if(calendar.view.type.startsWith('timeGrid')&&Number.isFinite(event.clientY)){
const lane=[...byId('workCalendar').querySelectorAll('.fc-timegrid-slot-lane[data-time]')].find(el=>{const rect=el.getBoundingClientRect();return event.clientY>=rect.top&&event.clientY<rect.bottom;});
clock=lane?.dataset.time?.slice(0,5)||null;
}
return {day,clock,cell};
}
root.addEventListener('click',event=>{
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=>`<button type="button" class="work-employee-drop" data-drop-user="${u.id}" title="Træk en opgave hertil, eller klik for at se køen"><i class="bi bi-person me-1" aria-hidden="true"></i>${esc(u.name)}</button>`).join('')+'<div class="work-employee-drop" data-drop-unassign title="Træk en opgave fra køen hertil for at fjerne medarbejderens tildeling"><i class="bi bi-inbox me-1" aria-hidden="true"></i>Træk hertil for at fjerne fra køen</div>';
}
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=>`<article class="card work-card"><h2 class="h5">${esc(r.name)}</h2><p>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)}</p><p>Overbelastning (prognose): ${r.norm_configured?minutes(r.overload_minutes):'kan ikke beregnes'} · Ufordelte estimater: ${r.unestimated_count}</p>${(r.deadline_risks||[]).map(w=>`<p class="text-danger"><a href="/sag/${w.sag_id}">#${w.sag_id}</a> · ${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)}</p>`).join('')}</article>`).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=>`<option value="${u.id}" ${u.id===target?'selected':''}>${esc(u.name)}</option>`).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=>`<option value="${u.id}" ${Number(u.id)===uid?'selected':''}>${esc(u.name||u.full_name||u.username)}</option>`).join('');}
byId('workAbsenceForm').elements.kind.innerHTML=types.filter(t=>t.active&&t.is_absence).map(t=>`<option value="${esc(t.code)}">${esc(t.label)}</option>`).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);
})();