413 lines
66 KiB
JavaScript
413 lines
66 KiB
JavaScript
/* Unified planning uses existing cases and time APIs. Opening work never starts time. */
|
||
(() => {
|
||
'use strict';
|
||
const root = document.querySelector('.work-page');
|
||
if (!root) return;
|
||
const uid = Number(root.dataset.userId);
|
||
const byId = id => document.getElementById(id);
|
||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const local = date => new Date(date.getTime() - date.getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||
const api = async (path, options={}) => {
|
||
const response = await fetch('/api/v1/planner'+path, {...options, headers: {'Content-Type':'application/json', ...(options.headers||{})}});
|
||
const text = await response.text();
|
||
let body;
|
||
try { body = JSON.parse(text); }
|
||
catch (_) { throw new Error(response.status >= 500 ? 'Planlægningen kunne ikke indlæses på grund af en serverfejl. Prøv igen efter databaseopdateringen.' : 'Serveren returnerede et ugyldigt svar. Genindlæs siden eller log ind igen.'); }
|
||
if (!response.ok) { const e = new Error(typeof body.detail === 'string' ? body.detail : body.detail?.message || 'Handlingen kunne ikke gennemføres'); e.detail=body.detail; throw e; }
|
||
return body;
|
||
};
|
||
let queue = {items:[],version:1}, metadata, calendar, dragged, target=uid, queueRequest=0, activityTypes=[];
|
||
const error = e => {byId('workError').textContent=e.message;byId('workError').hidden=false;};
|
||
const minutes = n => n == null ? 'Ukendt estimat' : `${Math.round(n)} min`;
|
||
function taskControls(row) {
|
||
const slots=row.planned_slots||[],id=row.sag_id||row.id;
|
||
const day=value=>value?String(value).split('-').reverse().join('.'):'Ikke sat';
|
||
const start=day(row.start_date)+(row.start_time?' kl. '+row.start_time.slice(0,5):'');
|
||
const deadline=row.deadline?new Intl.DateTimeFormat('da-DK',{dateStyle:'short',timeStyle:'short',timeZone:'Europe/Copenhagen'}).format(new Date(row.deadline)):'Ikke sat';
|
||
const buttons=[['start','Start','bi-calendar-plus',start,canEditDates()],['deadline','Deadline','bi-flag',deadline,canEditDates()],['slot','Allokeret','bi-calendar2-range',slots.length?`${slots.length} tidsrum`:'Ingen tider',metadata.editable_user_ids.includes(target)]];
|
||
return `<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);
|
||
})();
|