bmc_hub/static/js/planner.js
Christian abfb945679 feat(planner): add overdue cases handling and improve case rendering
- Introduced `overdueCases` state to track overdue cases in the planner.
- Enhanced `loadCases` function to fetch and store overdue cases.
- Updated `renderCases` to display overdue cases with appropriate labels.
- Modified case card rendering to highlight overdue cases.

fix(telefoni): ensure WebSocket connection on DOMContentLoaded

- Adjusted WebSocket connection logic to handle cases where the script loads after DOMContentLoaded.

test(tests): add tests for case creation and internal ticket handling

- Added tests to ensure ticket creation commits all optional data.
- Implemented tests for internal ticket creation, ensuring correct customer and user assignments.

feat(migrations): add new tables for email configuration and case assignments

- Created `email_connection_configurations` table for managing email settings.
- Introduced `sag_case_assignees` table to handle individual case assignments.
- Added `sag_case_details` table for structured case type details.
- Updated existing case types to align with new templates and added support for internal tickets.

fix(timetracking): allow manual time creation without case customer

- Modified manual time creation to support entries without a customer.
- Updated settlement options to recommend internal settlement for customerless time.

feat(v4): enhance case detail template with new features

- Added new features to the case detail template, including compact icons and inline relation creation.
- Improved comment actions and time tracking functionalities.
2026-09-20 03:33:10 +02:00

46 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

(() => {
const root=document.querySelector('.planner-page'); if(!root)return;
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],overdueCases:[],quick:'unplanned',selected:null,caseRefresh:0};
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtMin=m=>m?`${Math.floor(m/60)}t${m%60?' '+m%60+'m':''}`:'0t';
const api=async(url,opt={})=>{const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opt});let b;try{b=await r.json()}catch{b={detail:'Ukendt serverfejl'}}if(!r.ok){const e=new Error(typeof b.detail==='string'?b.detail:b.detail?.message||'Handlingen mislykkedes');e.status=r.status;e.body=b;throw e}return b};
function range(){let a=new Date(state.anchor),b;if(state.view==='day'){a.setHours(0,0,0,0);b=new Date(a);b.setDate(b.getDate()+1)}else if(state.view==='week'){a.setHours(0,0,0,0);a.setDate(a.getDate()-((a.getDay()+6)%7));b=new Date(a);b.setDate(b.getDate()+7)}else{a=new Date(a.getFullYear(),a.getMonth(),1);b=new Date(a.getFullYear(),a.getMonth()+1,1)}return[a,b]}
function selectedUsers(){return state.scope==='mine'?[Number(root.dataset.currentUser)]:state.meta.users.map(u=>u.id)}
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial','overdue'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{const current=await api('/api/v1/planner/cases?'+p);let overdueItems=[];try{overdueItems=(await api('/api/v1/planner/cases?planning=overdue&sort=priority')).items||[]}catch(error){console.warn('Kunne ikke hente overskredne planer',error)}state.cases=current.items;state.overdueCases=overdueItems;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const currentType=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===currentType?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
function caseCardHtml(c){const overdue=Boolean(c.overdue_planned),overdueDate=overdue&&c.overdue_planned_until?new Date(c.overdue_planned_until).toLocaleDateString('da-DK'):'';return `<article class="case-card ${overdue?'overdue-planned':''}" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div>${overdue?`<div class="case-overdue-label"><i class="bi bi-exclamation-triangle-fill"></i>Planlagt afsluttet ${esc(overdueDate)} · stadig åben</div>`:''}<div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`}
function renderCases(){const overdueHtml=state.quick!=='overdue'&&state.overdueCases.length?`<section class="overdue-case-section"><div class="overdue-case-heading"><i class="bi bi-exclamation-triangle-fill"></i>${state.overdueCases.length} åben${state.overdueCases.length===1?' sag er':' sager er'} planlagt før i dag</div>${state.overdueCases.map(caseCardHtml).join('')}</section>`:'';const casesHtml=state.cases.length?state.cases.map(caseCardHtml).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';$('#caseList').innerHTML=overdueHtml+casesHtml;document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
function render(a,b){const opts={day:'numeric',month:'short'};$('#periodLabel').textContent=state.view==='month'?a.toLocaleDateString('da-DK',{month:'long',year:'numeric'}):`${a.toLocaleDateString('da-DK',opts)} – ${new Date(b-1).toLocaleDateString('da-DK',{...opts,year:'numeric'})}`;state.view==='month'?renderMonth(a):renderTimeline(a,b)}
function cap(uid,key){return state.data.capacity.find(c=>c.user_id===uid&&c.date===key)||{state:'gray',label:'Ingen arbejdstid'}}
function canEdit(uid){return (state.meta.editable_user_ids||[]).includes(Number(uid))}
function integrationWarning(){return (state.data.integration_statuses||[]).some(x=>x.status==='degraded')?'<div class="alert alert-warning m-2 py-2"><i class="bi bi-cloud-slash me-1"></i>Ekstern travl tid kan mangle. Intern planlægning virker fortsat.</div>':''}
function blockStyle(start,end,lane={index:0,count:1}){const first=7*60,last=18*60,total=last-first,s=new Date(start),e=new Date(end),sm=s.getHours()*60+s.getMinutes(),em=e.getHours()*60+e.getMinutes(),top=Math.max(0,(sm-first)/total*660),height=Math.max(24,(Math.min(last,em)-Math.max(first,sm))/total*660),left=lane.index*(100/lane.count),leftPx=26-lane.index*(29/lane.count),width=100/lane.count,widthPx=29/lane.count;return `top:${top}px;height:${height}px;left:calc(${left}% + ${leftPx}px);right:auto;width:calc(${width}% - ${widthPx}px)`}
function overlapLayout(planned,registered){const all=[...planned.map(item=>({key:`p:${item.id}`,item})),...registered.map(item=>({key:`r:${item.id}`,item}))].sort((a,b)=>new Date(a.item.starts_at)-new Date(b.item.starts_at));const result=new Map();let group=[],groupEnd=0;const flush=()=>{if(!group.length)return;const laneEnds=[];for(const event of group){const start=+new Date(event.item.starts_at);let lane=laneEnds.findIndex(end=>end<=start);if(lane<0)lane=laneEnds.length;laneEnds[lane]=+new Date(event.item.ends_at);event.lane=lane}for(const event of group)result.set(event.key,{index:event.lane,count:laneEnds.length});group=[];groupEnd=0};for(const event of all){const start=+new Date(event.item.starts_at),end=+new Date(event.item.ends_at);if(group.length&&start>=groupEnd)flush();group.push(event);groupEnd=Math.max(groupEnd,end)}flush();return result}
function hourMarks(){return Array.from({length:12},(_,i)=>`<span class="hour-mark" style="top:${i*60}px">${pad(i+7)}:00</span>`).join('')}
function registeredHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="registered-time" style="${blockStyle(x.starts_at,x.ends_at,lane)}"><div class="time"><i class="bi bi-stopwatch me-1"></i>${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())} · registreret</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${fmtMin(x.minutes)} brugt</div></div>`}
function untimedHtml(items){return items.length?`<div class="untimed-registers">${items.map(x=>`<div class="untimed-register" title="${esc(x.case_title)}"><i class="bi bi-stopwatch me-1"></i>Uden tidspunkt · #${x.sag_id} · ${fmtMin(x.minutes)} registreret</div>`).join('')}</div>`:''}
function renderTimeline(a,b){const users=state.meta.users.filter(u=>selectedUsers().includes(u.id)),days=[];for(let d=new Date(a);d<b;d.setDate(d.getDate()+1))days.push(new Date(d));const cols=`150px repeat(${days.length},minmax(130px,1fr))`;let html=integrationWarning()+`<div class="week-grid" style="grid-template-columns:${cols}"><div class="grid-cell grid-head employee-head">Medarbejder</div>${days.map(d=>`<div class="grid-cell grid-head">${d.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'})}</div>`).join('')}`;for(const u of users){html+=`<div class="grid-cell employee-head">${esc(u.name)}<div class="small text-muted mt-2">Arbejdsdag 07–18</div></div>`;for(const d of days){const key=dateKey(d),c=cap(u.id,key),items=state.data.allocations.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),allRegistered=(state.data.time_entries||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),registered=allRegistered.filter(x=>x.has_time),untimed=allRegistered.filter(x=>!x.has_time),busy=state.data.external_busy.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),lanes=overlapLayout(items,registered);html+=`<div class="grid-cell drop-cell" data-user="${u.id}" data-date="${key}">${hourMarks()}<span class="capacity-pill"><i class="dot ${c.state}"></i>${esc(c.label)} · ${esc(c.registered_label||'0 timer registreret')}</span>${busy.map(x=>`<div class="external-busy" style="${blockStyle(x.starts_at,x.ends_at)}">Ekstern travl tid</div>`).join('')}${registered.map(x=>registeredHtml(x,lanes.get(`r:${x.id}`))).join('')}${items.map(x=>allocationHtml(x,lanes.get(`p:${x.id}`))).join('')}${untimedHtml(untimed)}</div>`}}html+='</div>';$('#timeline').innerHTML=html;bindCalendar()}
function allocationHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="allocation ${x.conflict_status!=='none'?'conflict':''} ${x.locked?'locked':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}" draggable="${x.locked?'false':'true'}" data-allocation="${x.id}"><div class="time">${x.locked?'<i class="bi bi-lock-fill me-1"></i>':''}${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())}</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${esc(x.customer_name)}</div></div>`}
function renderMonth(a){const start=new Date(a);start.setDate(1-((start.getDay()+6)%7));let html=integrationWarning()+'<div class="month-grid">'+['Man','Tir','Ons','Tor','Fre','Lør','Søn'].map(x=>`<div class="grid-cell grid-head">${x}</div>`).join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(d.getDate()+i);const key=dateKey(d),caps=selectedUsers().map(uid=>cap(uid,key)),alloc=state.data.allocations.filter(x=>dateKey(new Date(x.starts_at))===key),planned=caps.reduce((n,c)=>n+c.allocated_minutes,0),registered=caps.reduce((n,c)=>n+(c.registered_minutes||0),0),available=caps.reduce((n,c)=>n+c.available_minutes,0);html+=`<div class="month-day ${d.getMonth()!==a.getMonth()?'other':''}" data-date="${key}"><span class="day-num">${d.getDate()}</span><div class="month-cap">${caps.map(c=>`<span title="${esc(c.label)}"><i class="dot ${c.state}"></i></span>`).join('')} ${fmtMin(planned)} af ${fmtMin(available)} planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${fmtMin(registered)} registreret</span></div>${alloc.slice(0,3).map(x=>`<div class="allocation" data-allocation="${x.id}">#${x.sag_id} ${esc(x.case_title)}</div>`).join('')}</div>`}html+='</div>';$('#timeline').innerHTML=html;document.querySelectorAll('.month-day').forEach(el=>el.onclick=e=>{if(e.target.closest('.allocation'))return;state.anchor=new Date(el.dataset.date+'T12:00');state.view='day';document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view==='day'));loadPeriod()});bindCalendar()}
function bindCalendar(){document.querySelectorAll('.drop-cell').forEach(el=>{if(canEdit(el.dataset.user)){el.ondragover=e=>e.preventDefault();el.ondrop=e=>drop(e,el)}});document.querySelectorAll('[data-allocation]').forEach(el=>{const item=state.data.allocations.find(x=>x.id===Number(el.dataset.allocation));el.onclick=()=>openAllocation(Number(el.dataset.allocation));el.draggable=Boolean(item&&canEdit(item.user_id)&&!item.locked);el.ondragstart=e=>{if(item&&canEdit(item.user_id)&&!item.locked)e.dataTransfer.setData('application/x-allocation',el.dataset.allocation)}})}
async function drop(e,cell){e.preventDefault();const caseId=e.dataTransfer.getData('application/x-case'),allocId=e.dataTransfer.getData('application/x-allocation');const rect=cell.getBoundingClientRect(),ratio=Math.max(0,Math.min(1,(e.clientY-rect.top)/rect.height)),minutes=Math.round((7*60+ratio*11*60)/15)*15;const start=new Date(cell.dataset.date+'T00:00');start.setMinutes(minutes);if(caseId){const c=state.cases.find(x=>x.id===Number(caseId)),defaultMins=Math.max(15,Number(state.meta.settings.default_allocation_minutes)||60),remainingMins=Math.max(15,Number(c?.remaining_minutes)||defaultMins),mins=Math.min(remainingMins,defaultMins);await mutate('/api/v1/planner/allocations','POST',{sag_id:Number(caseId),user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+mins*60000).toISOString(),source:'manual'})}else if(allocId){const x=state.data.allocations.find(a=>a.id===Number(allocId)),dur=new Date(x.ends_at)-new Date(x.starts_at);await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+dur).toISOString(),version:x.version})}}
function openAllocation(id){const x=state.data.allocations.find(a=>a.id===id);if(!x)return;state.selected=x;const editable=canEdit(x.user_id);$('#saveAllocation').classList.toggle('d-none',!editable);$('#deleteAllocation').classList.toggle('d-none',!editable||x.locked);$('#copyAllocation').classList.toggle('d-none',!editable);$('#dialogTitle').textContent=`#${x.sag_id} · ${x.case_title}`;$('#dialogCustomer').textContent=`${x.customer_name} · ${x.user_name}`;$('#dialogStart').value=localISO(new Date(x.starts_at));$('#dialogEnd').value=localISO(new Date(x.ends_at));$('#dialogNote').value=x.note||'';$('#dialogLocked').checked=Boolean(x.locked);$('#dialogStart').disabled=!editable;$('#dialogEnd').disabled=!editable;$('#dialogNote').disabled=!editable;$('#dialogLocked').disabled=!editable;$('#dialogCaseLink').href=`/sag/${x.sag_id}/v3`;$('#dialogWarning').classList.add('d-none');$('#allocationDialog').showModal()}
async function saveSelected(){const x=state.selected;await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{starts_at:new Date($('#dialogStart').value).toISOString(),ends_at:new Date($('#dialogEnd').value).toISOString(),note:$('#dialogNote').value,locked:$('#dialogLocked').checked,version:x.version},true)}
async function deleteSelected(){const x=state.selected;if(!confirm('Slet denne tidsblok? Sagen bevares.'))return;try{const result=await api(`/api/v1/planner/allocations/${x.id}?version=${x.version}`,{method:'DELETE'});$('#allocationDialog').close();const a=result.allocation;toast('Tidsblokken blev slettet','Fortryd',()=>undoMutation(a.id,a.version));await refresh()}catch(e){toast(e.message)}}
async function copySelected(){const x=state.selected,start=new Date(x.starts_at);start.setDate(start.getDate()+1);await mutate(`/api/v1/planner/allocations/${x.id}/copy?starts_at=${encodeURIComponent(start.toISOString())}`,'POST',null,true)}
async function mutate(url,method,body,close=false){try{const result=await api(url,{method,body:body?JSON.stringify(body):undefined});if(close)$('#allocationDialog').close();toast('Planen er gemt','Fortryd',result?.id&&result?.version?()=>undoMutation(result.id,result.version):null);await refresh()}catch(e){if(e.status===409&&e.body?.detail?.conflict_status&&confirm(`${e.message}. Vil du gemme alligevel?`)){body.confirm_conflicts=true;return mutate(url,method,body,close)}toast(e.message);await loadPeriod()}}
async function undoMutation(id,version){try{await api(`/api/v1/planner/allocations/${id}/undo?version=${version}`,{method:'POST'});toast('Ændringen blev fortrudt');await refresh()}catch(e){toast(e.message);await loadPeriod()}}
async function refresh(){await Promise.all([loadCases(),loadPeriod()])}
function toast(message,action,fn){const el=document.createElement('div');el.className='planner-toast';el.textContent=message;if(action&&fn){const b=document.createElement('button');b.textContent=action;b.onclick=()=>{fn();el.remove()};el.append(b)}$('#toastStack').append(el);setTimeout(()=>el.remove(),6000)}
init().catch(e=>{$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`});
})();