bmc_hub/static/js/work-desk.js
Christian 1fb5b4a191 Add comprehensive tests for email and drift functionalities
- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements.
- Added tests for email identity API to validate email linking and identity retrieval without updates.
- Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios.
- Created tests for email reply automation to ensure proper routing and processing of replies based on classification.
- Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies.
- Added tests for email sender identity to ensure correct matching of contacts and domains.
- Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2026-11-03 01:16:12 +01:00

415 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/* 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);
})();