bmc_hub/static/js/work-desk.js

415 lines
38 KiB
JavaScript
Raw Normal View History

/* The desk is a view of existing queues, bookings and actual time. */
(() => {
'use strict';
const root = document.getElementById('workDesk');
if (!root) return;
const $ = id => document.getElementById(id);
const uid = Number(root.dataset.userId);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;'}[c]));
const local = date => new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);
const dayKey = date => local(date).slice(0,10);
const fmt = minutes => minutes == null ? 'Ikke angivet' : `${Math.floor(minutes/60) ? Math.floor(minutes/60)+' t' : ''}${minutes%60 ? ' '+Math.round(minutes%60)+' min' : ''}`.trim() || '0 min';
const clock = value => new Date(value).toLocaleTimeString('da-DK', {hour:'2-digit',minute:'2-digit'});
const dateLabel = value => value ? new Date(value.length===10?value+'T12:00:00':value).toLocaleDateString('da-DK',{day:'numeric',month:'short'}) : 'Ikke sat';
const planner = '/api/v1/planner';
let calendarScrolledDay=null, workHours=[], desk, metadata, calendar, schedule = {}, selected = null, recent = [], undo = null, drag = null, busy = false, activeDay = dayKey(new Date()), requestSeq = 0;
async function api(path, options = {}) {
const response = await fetch(path, {...options, headers:{'Content-Type':'application/json',...(options.headers||{})}});
let result;
try {result=await response.json();} catch (_) {throw new Error('Serveren gav et ugyldigt svar. Genindlæs eller log ind igen.');}
if (!response.ok) {
const error = new Error(typeof result.detail==='string'?result.detail:result.detail?.message || 'Handlingen kunne ikke gennemføres');
error.status=response.status;error.detail=result.detail;throw error;
}
return result;
}
async function mutation(path, method, body) {
try {return await api(path,{method,body:JSON.stringify(body)});} catch(error) {
if (error.status===409 && (error.detail?.confirm_conflicts || error.detail?.conflict_status) && !body.confirm_conflicts && confirm(error.message+'. Vil du gemme alligevel?')) {
return api(path,{method,body:JSON.stringify({...body,confirm_conflicts:true})});
}
throw error;
}
}
function error(e) {$('deskError').textContent=e.message;$('deskError').hidden=false;}
function status(message, action = null) {
$('deskStatus').querySelector('span').textContent=message;
undo=action;$('deskUndo').hidden=!action;
}
function select(id) {
selected=Number(id)||null;
root.querySelectorAll('[data-case-row]').forEach(row=>row.classList.toggle('is-selected',Number(row.dataset.caseRow)===selected));
root.querySelectorAll('[data-calendar-case]').forEach(el=>el.classList.toggle('desk-calendar-selected',Number(el.dataset.calendarCase)===selected));
const row=desk?.items.find(r=>r.sag_id===selected);
$('deskSelection').textContent=row ? `#${row.sag_id} · ${row.priority_reason}. Resterende: ${fmt(row.remaining_work_minutes)}. Fremtidig allokering: ${fmt(row.future_planned_minutes)}.` : 'Vælg en opgave eller en aftale for at se sammenhængen.';
}
function contact(row) {
const person=row.contact;
if(!person)return '<span class="text-muted">Ingen kontakt</span>';
const phone=String(person.phone||'').replace(/[^+\d]/g,'');
const email=String(person.email||'');
return `<a class="desk-contact" href="/contacts/${Number(person.id)}">${esc(person.name||'Kontakt')}</a><div class="desk-contact-links">${phone?`<a href="tel:${esc(phone)}" aria-label="Ring til ${esc(person.name)}"><i class="bi bi-telephone" aria-hidden="true"></i></a>`:''}${email?`<a href="mailto:${esc(email)}" aria-label="Skriv til ${esc(person.name)}"><i class="bi bi-envelope" aria-hidden="true"></i></a>`:''}</div>`;
}
function attentionReasons(row, now) {
const reasons=[];
if(row.deadline && new Date(row.deadline)<now) reasons.push('Deadline overskredet');
if(row.missing_minutes>0) reasons.push(`Mangler ${fmt(row.missing_minutes)} i planen`);
if(row.blocked_reason) reasons.push(row.blocked_reason);
if(row.booking_after_deadline) reasons.push('Tid reserveret efter deadline');
return reasons;
}
function renderPressure(risk, overload, now) {
const pressure=$('deskPressure');
pressure.hidden=!risk.length&&!overload;
if(pressure.hidden) {
pressure.replaceChildren();
return;
}
const items=risk.map(row=>`<li><button type="button" class="btn btn-link p-0 text-start" data-attention-case="${Number(row.sag_id)}">#${Number(row.sag_id)} · ${esc(row.titel)} — ${esc(attentionReasons(row,now).join(' · '))}</button></li>`).join('');
pressure.innerHTML=`${risk.length?`<strong>${risk.length} opgave${risk.length===1?'':'r'} kræver opmærksomhed</strong><ul class="mb-0 mt-1">${items}</ul>`:''}${overload?`<div class="mt-2"><strong>Kapacitet:</strong> Dagens arbejde overstiger den resterende ledige arbejdstid med ${esc(fmt(overload))}.</div>`:''}`;
}
function render() {
const query=$('deskSearch').value.toLocaleLowerCase();
const daily=desk.items.filter(r=>r.in_day);
const rows=daily.filter(r=>[r.sag_id,r.titel,r.customer_name,r.contact?.name].join(' ').toLocaleLowerCase().includes(query));
const known=daily.filter(r=>r.remaining_work_minutes!=null);
const unknown=daily.length-known.length;
$('deskCount').textContent=String(daily.length);
$('deskEstimate').textContent=fmt(known.reduce((n,r)=>n+r.remaining_work_minutes,0))+(unknown?` + ${unknown} uden estimat`:'');
const dayStart=new Date(activeDay+'T00:00:00'),dayEnd=new Date(dayStart);dayEnd.setDate(dayEnd.getDate()+1);
const totalBooked=(schedule.bookings||[]).reduce((n,r)=>n+Math.max(0,(Math.min(new Date(r.ends_at),dayEnd)-Math.max(new Date(r.starts_at),dayStart))/60000),0);
$('deskAllocated').textContent=fmt(Math.round(totalBooked));
$('deskMissing').textContent=fmt(known.reduce((n,r)=>n+r.missing_minutes,0))+(unknown?' + ukendt':'');
const now=new Date();
const risk=daily.filter(r=>r.deadline && new Date(r.deadline)<new Date(activeDay+'T23:59:59') && (new Date(r.deadline)<now || r.missing_minutes>0 || r.blocked_reason || r.booking_after_deadline));
const unplannedToday=known.filter(r=>!r.blocked_reason).reduce((n,r)=>n+Math.max(0,r.remaining_work_minutes-r.planned_today_minutes),0);
const overload=desk.capacity?.configured?Math.max(0,unplannedToday-desk.capacity.free_minutes):0;
renderPressure(risk,overload,now);
$('deskOrderLabel').textContent=desk.manual_order?'Din gemte rækkefølge · træk for at ændre':'Anbefalet: deadline, faste tider, start og resterende arbejde';
$('deskRecommend').disabled=!desk.can_edit || !desk.manual_order;
const incoming=desk.items.filter(r=>r.is_new && r.work_status!=='done');
$('deskInbox').hidden=!incoming.length;
$('deskInboxCount').textContent=String(incoming.length);
$('deskSeen').disabled=!desk.can_edit;
$('deskInboxItems').innerHTML=incoming.map(r=>`<a class="btn btn-sm btn-outline-primary" href="/sag/${r.sag_id}/v5">#${r.sag_id} · ${esc(r.titel)}${!r.in_day?' · senere':''}</a>`).join('');
$('deskTasks').innerHTML=rows.map(r=>{
const overdue=Boolean(r.deadline && new Date(r.deadline)<now);
return `<tr data-case-row="${r.sag_id}" class="${overdue?'desk-task-overdue':''}">
<td><button class="btn btn-sm btn-light desk-grip" data-reorder="${r.sag_id}" draggable="${desk.can_edit}" ${desk.can_edit?'':'disabled'} aria-label="Træk for at flytte opgave #${r.sag_id}">⠿</button></td>
<td><a class="desk-case" href="/sag/${r.sag_id}/v5">${overdue?'<span class="visually-hidden">Deadline overskredet: </span>':''}${esc(r.titel)}</a><span class="desk-sub">#${r.sag_id} · ${esc(r.customer_name||'Intern sag')}${r.is_new?' · NY':''}</span><span class="desk-reason ${r.blocked_reason?'text-warning-emphasis':''}">${esc(r.priority_reason)}</span>${r.active_timer?'<span class="desk-sub">● Timer aktiv</span>':''}</td>
<td>${contact(r)}</td>
<td><span>${esc(dateLabel(r.deadline))}${r.deadline?' · '+clock(r.deadline):''}</span><span class="desk-sub">Start: ${esc(dateLabel(r.start_date))}${r.start_time?' '+esc(r.start_time.slice(0,5)):''}</span>${r.today_slots.map(s=>`<span class="desk-sub">${clock(s.starts_at)}–${clock(s.ends_at)}</span>`).join('')}</td>
<td><form data-estimate="${r.sag_id}" class="desk-minutes"><input class="form-control form-control-sm" type="number" min="0" max="525600" step="1" name="minutes" value="${r.estimated_minutes??''}" placeholder="—" ${desk.can_edit?'':'disabled'} aria-label="Estimat i minutter for sag ${r.sag_id}"><button class="btn btn-sm btn-outline-secondary" ${desk.can_edit?'':'disabled'} aria-label="Gem estimat for sag ${r.sag_id}">✓</button></form><span class="desk-sub">Minutter · din del</span><span class="desk-sub">Rest: ${esc(fmt(r.remaining_work_minutes))}</span></td>
<td><strong class="desk-number">${esc(fmt(r.planned_today_minutes))}</strong><span class="desk-sub">I dag</span><span class="desk-sub">Fremtid: ${esc(fmt(r.future_planned_minutes))}</span><span class="desk-sub">${r.missing_minutes==null?'Estimat mangler':r.missing_minutes?'Mangler '+fmt(r.missing_minutes):'Tid er reserveret'}</span><button class="btn btn-sm btn-outline-primary mt-2" draggable="${desk.can_edit}" data-plan="${r.sag_id}" ${desk.can_edit?'':'disabled'}>Planlæg</button></td>
<td><div class="d-flex flex-column gap-1"><button class="btn btn-sm btn-outline-primary" data-start="${r.sag_id}" ${!r.ready?'disabled':''}>Start timer</button><button class="btn btn-sm btn-outline-secondary" data-time="${r.sag_id}">Registrér tid</button><button class="btn btn-sm btn-light" data-up="${r.sag_id}" ${desk.can_edit?'':'disabled'} aria-label="Flyt opgave op">↑</button><button class="btn btn-sm btn-light" data-down="${r.sag_id}" ${desk.can_edit?'':'disabled'} aria-label="Flyt opgave ned">↓</button></div></td></tr>`;
}).join('') || '<tr><td colspan="7" class="text-center py-4">Ingen opgaver i denne visning. Se “Alle opgaver” for fremtidige opgaver.</td></tr>';
select(selected);
}
async function load() {
const seq=++requestSeq;
const result=await api(planner+'/desk?day='+activeDay);
if(seq!==requestSeq)return;
desk=result;$('deskError').hidden=true;render();
}
async function refresh() {await load();calendar?.refetchEvents();await loadRecent();}
function dialog(title, fields, submit, label='Gem') {
$('deskDialogTitle').textContent=title;$('deskFields').innerHTML=fields;$('deskFormError').textContent='';$('deskSave').textContent=label;
$('deskForm').onsubmit=async event=>{
event.preventDefault();if(busy)return;busy=true;
const controls=[...$('deskForm').querySelectorAll('button,input,select,textarea')],disabled=controls.map(c=>c.disabled);controls.forEach(c=>c.disabled=true);
try {await submit($('deskForm'));$('deskDialog').close();await refresh();} catch(e) {$('deskFormError').textContent=e.message;}
finally {busy=false;controls.forEach((c,i)=>c.disabled=disabled[i]);}
};
$('deskDialog').showModal();
}
const field=(label,name,value='',type='text',extra='')=>`<label>${label}<input class="form-control" name="${name}" type="${type}" value="${esc(value)}" ${extra}></label>`;
const atDay=(clockValue='09:00')=>new Date(activeDay+'T'+clockValue);
async function saveOrder(ids, mode='manual') {
const before={ids:desk.stored_order_ids||desk.items.map(r=>r.sag_id),mode:desk.manual_order?'manual':'recommended'};
const response=await mutation(planner+'/desk/order','PUT',{ids,mode,version:desk.version});
status(mode==='manual'?'Din rækkefølge er gemt.':'Anbefalet rækkefølge er valgt.',async()=>{
await mutation(planner+'/desk/order','PUT',{...before,version:response.version});
});
await load();
}
async function reorder(id,beforeId) {
if(!desk.can_edit || id===beforeId)return;
const ids=desk.items.map(r=>r.sag_id).filter(sid=>sid!==id);
const index=beforeId?ids.indexOf(beforeId):ids.length;
ids.splice(index<0?ids.length:index,0,id);
await saveOrder(ids);
}
function eventBody(r,start,end) {
return {title:r.title,description:r.description,sag_id:r.sag_id,kind:r.kind||'work',user_ids:r.participants?.length?r.participants:[uid],
version:r.event_version,starts_at:start,ends_at:end};
}
function canMove(r) {return !r.private && !r.locked && (r.participants?.length?r.participants:[uid]).every(id=>metadata.editable_user_ids.includes(id));}
async function changeBooking(info) {
const r=info.event.extendedProps.booking;
if(!r || !canMove(r)){info.revert();return;}
const oldStart=r.starts_at,oldEnd=r.ends_at;
const path=planner+(r.event_id?'/events/'+r.event_id:'/allocations/'+r.id);
const body=r.event_id?eventBody(r,info.event.start.toISOString(),info.event.end.toISOString()):{version:r.version,starts_at:info.event.start.toISOString(),ends_at:info.event.end.toISOString()};
try {
const saved=await mutation(path,'PATCH',body);
const version=r.event_id?saved.event.version:saved.version;
status('Aftalen er opdateret.',async()=>{
await mutation(path,'PATCH',r.event_id?{...eventBody(r,oldStart,oldEnd),version}:{version,starts_at:oldStart,ends_at:oldEnd});
});
await refresh();
} catch(e) {info.revert();error(e);calendar.refetchEvents();}
}
function reservationDuration(row) {
const minutes=row.missing_minutes>0?row.missing_minutes:row.remaining_work_minutes>0?row.remaining_work_minutes:metadata.settings?.default_allocation_minutes||60;
return Math.max(15,Math.min(1440,Math.ceil(minutes/15)*15));
}
async function saveReservation(row,start,end) {
const result=await mutation(planner+'/events','POST',{title:row.titel,sag_id:row.sag_id,user_ids:[uid],kind:'work',starts_at:start.toISOString(),ends_at:end.toISOString()});
status(`Tid til #${row.sag_id} er reserveret kl. ${clock(start)}–${clock(end)}.`,async()=>api(planner+`/events/${result.event.id}?version=${result.event.version}`,{method:'DELETE'}));
}
async function dropReservation(row,start) {
if(busy||!desk.can_edit||!row)return;
busy=true;
try {await saveReservation(row,start,new Date(start.getTime()+reservationDuration(row)*60000));await refresh();}
catch(e){error(e);}finally{busy=false;}
}
function plan(row,start=atDay()) {
const duration=reservationDuration(row);
dialog('Planlæg tid · #'+row.sag_id,`<p>${esc(row.titel)}</p><div class="desk-two">${field('Fra','start',local(start),'datetime-local','required')}${field('Til','end',local(new Date(start.getTime()+duration*60000)),'datetime-local','required')}</div><p class="small text-muted">Reserverer tid til dig. Faktisk arbejde registreres separat.</p>`,async form=>{
const from=new Date(form.elements.start.value),to=new Date(form.elements.end.value);
if(to<=from)throw new Error('Slut skal ligge efter start');
await saveReservation(row,from,to);
},'Reservér tid');
}
function registerTime(sid=null,description='') {
const row=desk.items.find(r=>r.sag_id===sid);
const start=new Date(Date.now()-30*60000),requestId=crypto.randomUUID();
const options=desk.items.map(r=>`<option value="${r.sag_id}" ${r.sag_id===sid?'selected':''}>#${r.sag_id} · ${esc(r.titel)}</option>`).join('');
let pending=null;
dialog('Registrér faktisk arbejdstid',`<label>Sag · valgfrit<select name="sag_id" class="form-select"><option value="">Uden sag · internt arbejde</option>${options}</select></label>${field('Hvad har du lavet?','description',description||row?.titel||'','text','required maxlength="2000"')}<div class="desk-two">${field('Start','start',local(start),'datetime-local','required')}${field('Minutter','minutes','30','number','min="1" max="1440" required')}</div><p class="small text-muted">Gemmes som faktisk arbejdstid til kontrol. Der oprettes ingen sag eller kalenderaftale.</p>`,async form=>{
if(!pending) {
const selectedCase=Number(form.elements.sag_id.value)||null;
const body={request_id:requestId,description:form.elements.description.value,starts_at:new Date(form.elements.start.value).toISOString(),minutes:Number(form.elements.minutes.value)};
pending=selectedCase?{path:planner+'/desk/time',body:{...body,sag_id:selectedCase}}:{path:'/api/v1/timetracking/quick-time',body:{...body,mode:'registered',user_ids:[uid]}};
}
try{await mutation(pending.path,'POST',pending.body);}catch(e){if(e.status && e.status<500){pending=null;}else{e.message+=' Tryk Gem igen for at genprøve samme registrering.';}throw e;}
status('Tiden er registreret.');
},'Gem tid');
}
function meetingPayload(form, requestId, minutes, customer, contact) {
const users=[...form.querySelectorAll('[name=person]:checked')].map(el=>Number(el.value));
if(!users.length)throw new Error('Vælg mindst én medarbejder');
if(![15,30,45,60].includes(minutes))throw new Error('Vælg 15, 30, 45 eller 60 minutter');
return {request_id:requestId,mode:'registered',description:form.elements.description.value.trim()||'Internt møde',
minutes,starts_at:new Date(Date.now()-minutes*60000).toISOString(),user_ids:users,customer_id:customer,contact_id:contact};
}
async function meeting() {
const options=await api('/api/v1/timetracking/quick-time/options');
const people=options.users.filter(u=>u.can_register);
let duration=30,customer=null,contact=null,pending=null;
const requestId=crypto.randomUUID();
dialog('Registrér mødetid',`<fieldset><legend>Hvor længe?</legend><div class="desk-actions" data-meeting-durations>${[15,30,45,60].map(n=>`<button type="button" class="btn ${n===30?'btn-primary':'btn-outline-primary'}" data-duration="${n}" aria-pressed="${n===30}">${n} min</button>`).join('')}</div></fieldset><fieldset><legend>Medarbejdere</legend>${people.map(u=>`<label class="desk-check"><input type="checkbox" name="person" value="${u.id}" ${u.id===uid?'checked':''}>${esc(u.name)}</label>`).join('')}</fieldset>${field('Beskrivelse · valgfrit','description','Internt møde','text','maxlength="2000"')}<details><summary>Kunde og kontakt · valgfrit</summary>${field('Kunde','customer','','search','autocomplete="off" placeholder="Søg kunde…"')}<div data-meeting-customers class="list-group"></div>${field('Kontaktperson','contact','','search','autocomplete="off" placeholder="Søg kontakt…"')}<div data-meeting-contacts class="list-group"></div></details><p class="small text-muted mt-3">Registrerer afholdt tid, som slutter nu. Hver valgt medarbejder får det valgte antal minutter. Uden kunde gemmes tiden som internt arbejde hos jer.</p>`,async form=>{
if(!pending){
if(form.elements.customer.value.trim()&&!customer || form.elements.contact.value.trim()&&!contact)throw new Error('Vælg kunde og kontakt fra søgeresultaterne, eller ryd felterne');
pending=meetingPayload(form,requestId,duration,customer,contact);
}
try{await mutation('/api/v1/timetracking/quick-time','POST',pending);}catch(e){if(e.status&&e.status<500)pending=null;else e.message+=' Tryk Gem igen for at genprøve samme registrering.';throw e;}
status('Mødetiden er registreret for de valgte medarbejdere.');
},'Gem mødetid');
const form=$('deskForm');
form.querySelector('[data-meeting-durations]').onclick=event=>{
const button=event.target.closest('[data-duration]');if(!button)return;
duration=Number(button.dataset.duration);
form.querySelectorAll('[data-duration]').forEach(b=>{const active=Number(b.dataset.duration)===duration;b.classList.toggle('btn-primary',active);b.classList.toggle('btn-outline-primary',!active);b.setAttribute('aria-pressed',String(active));});
};
function search(name,type){
const input=form.elements[name],list=form.querySelector(type==='customers'?'[data-meeting-customers]':'[data-meeting-contacts]');
let generation=0,timer;
input.addEventListener('input',()=>{
const current=++generation;
if(type==='customers'){customer=null;contact=null;form.elements.contact.value='';form.querySelector('[data-meeting-contacts]').replaceChildren();}else contact=null;
clearTimeout(timer);list.replaceChildren();const term=input.value.trim();if(term.length<2)return;
timer=setTimeout(async()=>{
try{
const selectedCustomer=customer;
const data=await api(type==='contacts'&&selectedCustomer?`/api/v1/customers/${selectedCustomer}/contacts`:`/api/v1/search/${type}?q=${encodeURIComponent(term)}`);
if(current!==generation||!$('deskDialog').open||form.elements[name]!==input||type==='contacts'&&selectedCustomer!==customer)return;
let rows=Array.isArray(data)?data:data.results||data.items||[];
const label=r=>r.name||r.contact_name||[r.first_name,r.last_name].filter(Boolean).join(' ')||r.label||'';
if(type==='contacts'&&selectedCustomer)rows=rows.filter(r=>label(r).toLocaleLowerCase().includes(term.toLocaleLowerCase()));
list.replaceChildren();
for(const r of rows.slice(0,10)){
const button=document.createElement('button');button.type='button';button.className='list-group-item list-group-item-action';button.textContent=label(r);
button.onclick=()=>{if(type==='customers'){customer=Number(r.id);contact=null;form.elements.contact.value='';form.querySelector('[data-meeting-contacts]').replaceChildren();}else contact=Number(r.id);input.value=label(r);list.replaceChildren();};list.append(button);
}
}catch(e){$('deskFormError').textContent=e.message;}
},200);
});
}
search('customer','customers');search('contact','contacts');
}
async function registerMeeting(id) {
const details=await api(planner+`/desk/events/${id}/attendance`),event=details.event;
dialog('Registrér afholdt mødetid',`<p>${esc(event.title)}</p><p class="small text-muted">Vælg de faktiske deltagere. Start og slut kan rettes for hver person. Allerede registreret tid kan ikke oprettes igen.</p>${details.participants.map(p=>`<fieldset data-attendee="${p.user_id}"><legend>${esc(p.name)}${p.registered?' · registreret':''}</legend><label class="desk-check"><input type="checkbox" name="attend" ${p.can_register&&!p.registered?'checked':'disabled'}>Registrér tid for denne deltager</label><div class="desk-two">${field('Deltog fra','from_'+p.user_id,local(new Date(event.starts_at)),'datetime-local',p.registered||!p.can_register?'disabled':'required')}${field('Deltog til','to_'+p.user_id,local(new Date(event.ends_at)),'datetime-local',p.registered||!p.can_register?'disabled':'required')}</div></fieldset>`).join('')}`,async form=>{
const attendees=[...form.querySelectorAll('[data-attendee]')].filter(el=>el.querySelector('[name=attend]').checked).map(el=>({user_id:Number(el.dataset.attendee),starts_at:new Date(el.querySelector('[name^=from_]').value).toISOString(),ends_at:new Date(el.querySelector('[name^=to_]').value).toISOString()}));
if(!attendees.length)throw new Error('Vælg mindst én deltager, som ikke allerede er registreret');
await mutation(planner+`/desk/events/${id}/register`,'POST',{version:event.version,attendees});status('Afholdt mødetid er registreret.');
},'Registrér mødetid');
}
async function startTimer(sid=null,description='') {
if(busy)return;
busy=true;
try {await mutation(planner+'/desk/timer','POST',{sag_id:sid,description:description||desk.items.find(r=>r.sag_id===sid)?.titel||'Internt arbejde'});status('Timeren er startet.');await refresh();window.dispatchEvent(new CustomEvent('timer-state-changed'));}
finally {busy=false;}
}
async function loadRecent() {
try{recent=(await api(planner+'/desk/recent')).entries;$('deskContinue').disabled=!recent.length;}catch(e){$('deskContinue').disabled=true;}
}
function continueWork() {
const active=recent.find(r=>r.aktiv_timer&&!r.slut_tid);
if(active){status('Du har allerede en aktiv timer. Pause eller stop den i bundlinjen.');return;}
const paused=recent.find(r=>r.paused_at&&!r.slut_tid),last=paused||recent[0];
if(!last)return;
dialog('Fortsæt sidste arbejde',`<p>${esc(last.description||last.titel||'Internt arbejde')}</p><p class="small text-muted">${last.sag_id?'Sag #'+last.sag_id:'Uden sag'} · ${paused?'Genoptager samme pausede timer.':'Starter en ny timer med samme beskrivelse.'}</p>`,async()=>{
if(paused)await mutation('/api/v1/timetracking/time/resume','POST',{time_id:last.id});
else await mutation(planner+'/desk/timer','POST',{sag_id:last.sag_id,description:last.description||last.titel||'Internt arbejde'});
status('Arbejdet er genoptaget.');window.dispatchEvent(new CustomEvent('timer-state-changed'));
},'Fortsæt arbejdet');
}
function renderMeetings() {
const groups=new Map();
(schedule.bookings||[]).filter(r=>['meeting','internal'].includes(r.kind)&&!r.sag_id&&r.event_id).forEach(r=>groups.set(r.event_id,r));
$('deskMeetings').innerHTML=[...groups.values()].map(r=>`<div class="desk-meeting-row"><strong>${esc(r.title)}</strong><span class="desk-sub">${clock(r.starts_at)}–${clock(r.ends_at)}</span><button class="btn btn-sm btn-outline-primary" data-meeting-time="${r.event_id}">Registrér mødetid</button></div>`).join('');
}
function calendarColumns(events) {
for(const column of [0,1]) {
const rows=events.filter(e=>(e.extendedProps?.deskColumn||0)===column).sort((a,b)=>new Date(a.start)-new Date(b.start));
let cluster=[],ends=[],until=0;
function finish(){
for(const event of cluster){const width=50/ends.length,left=column*50+event.extendedProps.deskLane*width;event.extendedProps.deskLeft=left;event.extendedProps.deskRight=100-left-width;if(column===0)event.classNames=[...new Set([...(event.classNames||[]),'desk-allocated'])];}
cluster=[];ends=[];until=0;
}
for(const event of rows){
const start=new Date(event.start).getTime(),end=new Date(event.end).getTime();
if(start>=until)finish();
let lane=ends.findIndex(value=>value<=start);if(lane<0)lane=ends.length;
ends[lane]=end;until=Math.max(until,end);
event.extendedProps={...event.extendedProps,deskLane:lane};cluster.push(event);
}
finish();
}
return events;
}
function workingWindow(day) {
const weekday=(new Date(day+'T12:00:00').getDay()+6)%7;
const rows=workHours.filter(r=>r.weekday===weekday);
return {start:rows.length?rows.map(r=>r.start_time).sort()[0].slice(0,5):'07:00',end:rows.length?rows.map(r=>r.end_time).sort().at(-1).slice(0,5):'17:00'};
}
async function events(info,success,failure) {
try {
const data=await api(planner+`/schedule?starts_at=${encodeURIComponent(info.startStr)}&ends_at=${encodeURIComponent(info.endStr)}&user_id=${uid}`);
if(dayKey(info.start)!==activeDay){success([]);return;}
schedule=data;
const grouped=new Map();
(data.bookings||[]).forEach(r=>grouped.set(r.event_id?'event-'+r.event_id:'allocation-'+r.id,r));
const hours=workingWindow(activeDay);
let min=Number(hours.start.slice(0,2)),max=Math.ceil(Number(hours.end.slice(0,2))+Number(hours.end.slice(3))/60);
const bookings=[...grouped.values()].map(r=>{
const start=new Date(r.starts_at),end=new Date(r.ends_at);
min=Math.min(min,dayKey(start)<activeDay?0:start.getHours());max=Math.max(max,dayKey(end)>activeDay?24:end.getHours()+(end.getMinutes()>0?1:0));
return {id:r.event_id?'event-'+r.event_id:'allocation-'+r.id,title:(r.has_conflict?'⚠ ':'')+r.title,start:r.starts_at,end:r.ends_at,editable:canMove(r),extendedProps:{booking:r,sagId:r.sag_id,deskColumn:!r.kind||r.kind==='work'?0:1}};
});
const extra=(data.absences||[]).map(r=>({id:'absence-'+r.id,title:r.kind_label||'Fravær',start:r.starts_at,end:r.ends_at,editable:false,extendedProps:{deskColumn:1}}));
(data.standalone_plans||[]).forEach(r=>extra.push({id:'quick-'+r.id,title:r.title,start:r.starts_at,end:r.ends_at,editable:false}));
(data.registered||[]).forEach(r=>extra.push({id:'actual-'+r.id,title:'◷ '+r.title,start:r.starts_at,end:r.ends_at,editable:false,classNames:['desk-actual'],extendedProps:{sagId:r.sag_id,deskColumn:1}}));
(data.external_busy||[]).forEach(r=>extra.push({id:'external-'+r.id,title:'Optaget eksternt',start:r.starts_at,end:r.ends_at,editable:false,extendedProps:{deskColumn:1}}));
extra.forEach(r=>{const start=new Date(r.start),end=new Date(r.end);min=Math.min(min,dayKey(start)<activeDay?0:start.getHours());max=Math.max(max,dayKey(end)>activeDay?24:end.getHours()+(end.getMinutes()>0?1:0));});
const from=String(min).padStart(2,'0')+':00:00',to=String(max).padStart(2,'0')+':00:00';
const rangeChanged=calendar.getOption('slotMinTime')!==from||calendar.getOption('slotMaxTime')!==to;
if(calendar.getOption('slotMinTime')!==from)calendar.setOption('slotMinTime',from);
if(calendar.getOption('slotMaxTime')!==to)calendar.setOption('slotMaxTime',to);
const scroll=hours.start+':00';
if(calendar.getOption('scrollTime')!==scroll)calendar.setOption('scrollTime',scroll);
$('deskWorkHours').textContent=`Arbejdstid ${hours.start}–${hours.end}${min<Number(hours.start.slice(0,2))||max>Number(hours.end.slice(0,2))?' · rul for at se tider udenfor':''}`;
success(calendarColumns([...bookings,...extra]));
if(calendarScrolledDay!==activeDay||rangeChanged){calendarScrolledDay=activeDay;requestAnimationFrame(()=>calendar.scrollToTime(scroll));}
if(desk)render();renderMeetings();
}catch(e){failure(e);error(e);}
}
$('deskTasks').addEventListener('click',event=>{
const row=event.target.closest('[data-case-row]');if(row)select(row.dataset.caseRow);
const planButton=event.target.closest('[data-plan]'),start=event.target.closest('[data-start]'),timeButton=event.target.closest('[data-time]'),up=event.target.closest('[data-up]'),down=event.target.closest('[data-down]');
if(planButton)plan(desk.items.find(r=>r.sag_id===Number(planButton.dataset.plan)));
if(start)startTimer(Number(start.dataset.start)).catch(error);
if(timeButton)registerTime(Number(timeButton.dataset.time));
if(up||down){const id=Number(up?.dataset.up||down.dataset.down),visible=desk.items.filter(r=>r.in_day),index=visible.findIndex(r=>r.sag_id===id);if(up&&index>0)reorder(id,visible[index-1].sag_id).catch(error);if(down&&index<visible.length-1)reorder(id,visible[index+2]?.sag_id??null).catch(error);}
});
$('deskPressure').addEventListener('click',event=>{
const button=event.target.closest('[data-attention-case]');
if(!button)return;
const caseId=Number(button.dataset.attentionCase);
if($('deskSearch').value) {
$('deskSearch').value='';
render();
}
select(caseId);
const row=root.querySelector(`[data-case-row="${caseId}"]`);
row?.scrollIntoView({behavior:'smooth',block:'center'});
row?.querySelector('.desk-case')?.focus({preventScroll:true});
});
$('deskTasks').addEventListener('submit',async event=>{
const form=event.target.closest('[data-estimate]');if(!form)return;event.preventDefault();if(busy)return;busy=true;
const row=desk.items.find(r=>r.sag_id===Number(form.dataset.estimate)),old=row.estimated_minutes,value=form.elements.minutes.value;
try {
const saved=await mutation(planner+`/queue/${uid}/${row.sag_id}`,'PATCH',{version:desk.version,estimated_minutes:value===''?null:Number(value)});
status('Estimatet er gemt.',async()=>mutation(planner+`/queue/${uid}/${row.sag_id}`,'PATCH',{version:saved.version,estimated_minutes:old}));
await load();
}catch(e){error(e);await load().catch(()=>{});}finally{busy=false;}
});
root.addEventListener('dragstart',event=>{
const grip=event.target.closest('[data-reorder]'),slot=event.target.closest('[data-plan]');if(!grip&&!slot)return;
if(!desk.can_edit || busy){event.preventDefault();return;}
drag={id:Number(grip?.dataset.reorder||slot.dataset.plan),kind:grip?'order':'slot'};
event.dataTransfer.setData('application/x-bmc-desk',String(drag.id));event.dataTransfer.effectAllowed='move';
});
root.addEventListener('dragend',()=>{drag=null;root.querySelectorAll('.is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));});
$('deskTasks').addEventListener('dragover',event=>{if(drag?.kind==='order'){event.preventDefault();root.querySelectorAll('.is-drag-over').forEach(el=>el.classList.remove('is-drag-over'));event.target.closest('tr')?.classList.add('is-drag-over');}});
$('deskTasks').addEventListener('drop',async event=>{
if(drag?.kind!=='order')return;event.preventDefault();const id=drag.id;drag=null;
try{await reorder(id,Number(event.target.closest('[data-case-row]')?.dataset.caseRow)||null);}catch(e){error(e);await load().catch(()=>{});}
});
$('deskCalendar').addEventListener('dragover',event=>{if(drag?.kind==='slot')event.preventDefault();});
$('deskCalendar').addEventListener('drop',event=>{
if(drag?.kind!=='slot')return;event.preventDefault();
const row=desk.items.find(r=>r.sag_id===drag.id);drag=null;
const lane=[...$('deskCalendar').querySelectorAll('.fc-timegrid-slot-lane[data-time]')].find(el=>{const rect=el.getBoundingClientRect();return event.clientY>=rect.top && event.clientY<rect.bottom;});
if(!lane){status('Slip Planlæg på et klokkeslæt i kalenderen.');return;}
void dropReservation(row,atDay(lane.dataset.time.slice(0,5)));
});
$('deskUndo').onclick=async()=>{if(!undo||busy)return;busy=true;const action=undo;try{await action();status('Ændringen er fortrudt.');await refresh();}catch(e){error(e);status('Kunne ikke fortryde. Planen kan være ændret siden.');}finally{busy=false;}};
$('deskRecommend').onclick=()=>saveOrder([],'recommended').catch(error);
$('deskSeen').onclick=async()=>{try{await mutation(planner+'/desk/seen','POST',{});await load();}catch(e){error(e);}};
$('deskTime').onclick=()=>registerTime();$('deskMeeting').onclick=()=>meeting().catch(error);$('deskContinue').onclick=continueWork;
$('deskSearch').oninput=()=>desk&&render();$('deskClose').onclick=()=>$('deskDialog').close();
$('deskRefresh').onclick=()=>refresh().catch(error);
$('deskMeetings').onclick=event=>{const button=event.target.closest('[data-meeting-time]');if(button)registerMeeting(Number(button.dataset.meetingTime)).catch(error);};
async function init() {
[metadata,workHours]=await Promise.all([api(planner+'/metadata'),api(planner+'/work-schedules/'+uid)]);
$('deskMeeting').disabled=false;
calendar=new FullCalendar.Calendar($('deskCalendar'),{
locale:'da',initialView:'timeGridDay',firstDay:1,allDaySlot:false,height:720,scrollTime:workingWindow(activeDay).start+':00',slotMinTime:'07:00:00',slotMaxTime:'17:00:00',slotDuration:'00:15:00',slotLabelInterval:'01:00',snapDuration:'00:15:00',nowIndicator:true,editable:true,eventDurationEditable:true,
headerToolbar:{left:'prev,next today',center:'title',right:''},buttonText:{today:'I dag'},titleFormat:{weekday:'short',day:'numeric',month:'short'},
dayHeaderContent:()=>({html:'<div class="desk-column-headings"><span>Allokeret</span><span>Aftaler / registreret</span></div>'}),
events,eventDrop:changeBooking,eventResize:changeBooking,
eventDidMount:info=>{const props=info.event.extendedProps;if(props.deskLeft!=null){requestAnimationFrame(()=>{const harness=info.el.closest('.fc-timegrid-event-harness');if(harness){harness.classList.add('desk-column-event');harness.style.setProperty('--desk-event-left',props.deskLeft+'%');harness.style.setProperty('--desk-event-right',props.deskRight+'%');}});}if(info.event.extendedProps.sagId)info.el.dataset.calendarCase=info.event.extendedProps.sagId;select(selected);},
eventClick:info=>{const r=info.event.extendedProps.booking;select(info.event.extendedProps.sagId);if(r&&['meeting','internal'].includes(r.kind)&&!r.sag_id)registerMeeting(r.event_id).catch(error);},
datesSet:info=>{const day=dayKey(info.start);if(day!==activeDay){activeDay=day;load().catch(error);}$('deskDate').textContent=info.start.toLocaleDateString('da-DK',{weekday:'long',day:'numeric',month:'long',year:'numeric'});},
dateClick:info=>{dialog('Reservér tid til en opgave',`<label>Opgave<select name="case" class="form-select" required>${(desk?.items||[]).map(r=>`<option value="${r.sag_id}">#${r.sag_id} ${esc(r.titel)}</option>`).join('')}</select></label>`,async form=>{const row=desk.items.find(r=>r.sag_id===Number(form.elements.case.value));if(!row)throw new Error('Vælg en opgave');$('deskDialog').close();setTimeout(()=>plan(row,info.date),0);},'Vælg opgave');},
});
await load();calendar.render();await loadRecent();
setInterval(()=>{if(document.hidden||busy||drag||$('deskDialog').open||root.contains(document.activeElement)&&document.activeElement.matches('input,select,textarea'))return;refresh().catch(error);},30000);
}
init().catch(error);
})();