<sectionclass="d-none queue-obsolete"id="billingSelectionPanel"><divclass="card-body"><divclass="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2class="h5 mb-0">Valgte registreringer</h2><divclass="small text-muted">Behold forslagene, eller justér afregningen.</div></div><spanclass="badge text-bg-primary align-self-center"id="selectedBadge">0 valgt</span></div><divclass="d-flex justify-content-end mb-2"><buttonclass="btn btn-outline-primary me-2"id="approveBtn">Godkend</button><buttonclass="btn btn-primary"id="previewBtn"><iclass="bi bi-eye me-1"></i>Godkend og send / afregn…</button></div><detailsclass="queue-details d-none"id="manualBillingDetails"><summary>Justér afregning manuelt</summary><divclass="row g-2 align-items-end"><divclass="col-12 col-lg-3"><labelclass="form-label">Afregningstype</label><selectclass="form-select"id="settlementMethod"><optionvalue="">Behold den enkelte tids afregningstype</option><optionvalue="invoice">Faktura</option><optionvalue="prepaid">Klippekort</option><optionvalue="subscription">Abonnement / fast pris</option><optionvalue="internal">Intern tid</option><optionvalue="non_billable">Ikke-fakturerbar tid</option></select></div><divclass="col-12 col-lg-3"><labelclass="form-label">Klippekort</label><selectclass="form-select"id="prepaidCard"disabled><optionvalue="">Vælg først tider fra ét firma</option></select><divclass="form-text"id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><divclass="col-12 col-lg-2"><labelclass="form-label">Aftale-ID</label><inputid="agreementId"class="form-control"type="number"min="1"placeholder="Kun abonnement"></div><divclass="col-12 col-lg-4 d-flex gap-2"><buttonclass="btn btn-outline-primary flex-grow-1"id="setMethodBtn">Gem afregning</button></div></div></details></div></section>
<divid="workflowLineModeHost"class="mb-2 d-none"><labelclass="form-label d-flex align-items-center gap-2">Fakturalinjer<selectid="workflowLineMode"class="form-select form-select-sm"style="width:auto"><optionvalue="separate">En linje pr. tidsregistrering</option><optionvalue="combined">Saml ens faktureringsvilkår</option></select></label><pclass="small text-muted mb-0">Afregning ændres direkte på den enkelte registrering.</p></div><sectionid="previewPanel"class="card border-primary mb-3 d-none"><divclass="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><divclass="small text-muted">Dette er en kontrol uden ændringer. Kontrollér resultatet, før du godkender og sender eller afregner.</div></div><buttonclass="btn btn-sm btn-outline-secondary"id="closePreviewBtn">Luk</button></div><divclass="card-body"id="previewBody"></div><divclass="card-footer"><divclass="small text-muted mb-2">Faktura opretter en lokal ordrekladde. Klippekort trækker timer. Fastpris knytter timer til aftalen. Intet sendes eller bogføres i e-conomic her.</div><divclass="text-end"><buttonclass="btn btn-success"id="settleBtn"><iclass="bi bi-check2-circle me-1"></i>Godkend og gennemfør</button></div></div></section>
<divclass="modal fade"id="caseDetailModal"tabindex="-1"aria-labelledby="caseDetailTitle"aria-hidden="true"><divclass="modal-dialog modal-lg modal-dialog-scrollable"><divclass="modal-content"><divclass="modal-header"><div><divclass="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2class="modal-title h4 mb-0"id="caseDetailTitle">Sagsdetaljer</h2></div><buttontype="button"class="btn-close"data-bs-dismiss="modal"aria-label="Luk"></button></div><divclass="modal-body"id="caseDetailBody"></div><divclass="modal-footer"><aclass="btn btn-outline-primary"id="openCaseNewTab"target="_blank"rel="noopener"><iclass="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><buttontype="button"class="btn btn-secondary"data-bs-dismiss="modal">Luk</button></div></div></div></div>
function message(type,text){$('messageArea').innerHTML=`<divclass="alert alert-${type} alert-dismissible fade show">${esc(text)}<buttonclass="btn-close"data-bs-dismiss="alert"></button></div>`}
async function api(url,options={}){const r=await fetch(url,{headers:{'Content-Type':'application/json'},...options});const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='object'?(d.detail.message||'Handlingen kunne ikke gennemføres'):(d.detail||'Handlingen kunne ikke gennemføres'));e.payload=d;throw e}return d}
function actionable(row){return state.scope!=='history'&&(row.can_approve===true||row.can_send===true||row.can_settle===true);}
function approvalSnapshot(row){return {id:Number(row.id),updated_at:row.updated_at,entry_fingerprint:row.entry_fingerprint,billing_method:row.billing_method,prepaid_card_id:row.prepaid_card_id??null,fixed_price_agreement_id:row.fixed_price_agreement_id??null,workflow_revision:row.workflow_revision??row.workflow_decision?.revision??null,workflow_decision_id:row.workflow_decision?.id??null};}
function effectiveMethod(row){if(['sent','invoiced','settled'].includes(row.lifecycle_status))return norm(row.billing_method);if(row.workflow_active){const d=row.workflow_decision;if(row.workflow_stale||!d)return 'draft';if(d.status==='manual_review')return 'manual_review';if(!['decided','overridden','settled'].includes(d.status))return 'draft';return norm(d.method);}return norm(row.billing_method);}
function reviewIssue(row){if(['sent','invoiced','settled'].includes(row.lifecycle_status))return '';const m=effectiveMethod(row);if(row.workflow_active){if(row.workflow_stale)return 'Beslutningen er forældet. Genberegn under Hvorfor?.';if(!row.workflow_decision)return 'Afventer workflowbeslutning. Åbn Hvorfor?.';if(m==='manual_review'){const reasons={ambiguous_resource_coverage:'Flere aftaler eller kort matcher.',conflicting_rules:'Reglerne vælger forskellige metoder.',pending_tag_approval:'Tags afventer godkendelse.',insufficient_card_balance:'Klippekortet har ikke timer nok.',ambiguous_or_missing_cards:'Klippekort mangler eller er ikke entydigt.',ambiguous_or_missing_agreements:'Aftale mangler eller er ikke entydig.',no_matching_rule:'Ingen regel matcher.'};return (reasons[row.workflow_decision?.details?.reason]||'Afregningen kræver manuel vurdering.')+' Gennemgå under Hvorfor?.';}}if(m==='draft')return 'Afregning mangler.';if(['operations','development'].includes(m))return 'Afventer chefkontrol og valg af afregning.';if(!['invoice','prepaid','subscription','internal','non_billable','operations','development'].includes(m))return 'Afregningsmetoden skal gennemgås.';if(Number(row.approved_hours||row.original_hours||0)<=0)return 'Mangler positivt timeantal.';if(row.status==='rejected')return 'Tiden er afvist.';if(m==='invoice'&&row.billable===false)return 'Faktura er valgt på ikke-fakturerbar tid.';if(m==='invoice'&&!row.billing_customer_id&&!row.customer_id&&!row.hub_customer_id)return 'Mangler faktureringskunde.';if(m==='prepaid'&&!row.prepaid_card_id)return 'Mangler klippekort.';if(m==='subscription'&&!row.fixed_price_agreement_id)return 'Mangler aftale.';return '';}
function caseTags(row){return (row.case_tags||[]).map(tag=>`<spanclass="badge text-bg-light border me-1"title="${esc(tag.name)}"><iclass="bi bi-tag me-1"></i>${esc(tag.name)}</span>`).join('');}
function workflowExplanation(row){if(!row.workflow_active)return '';if(reviewIssue(row))return '';const reasons={resource_coverage:'Valgt ud fra aftalens eller kortets dækning.',matched_rule:'Valgt af en workflowregel.',manual_override:'Manuelt overstyret med begrundelse.',no_matching_rule:'Ingen regel matcher.'};return reasons[row.workflow_decision?.details?.reason]||'Valgt af workflow.';}
function selectionMode(rows){const active=rows.filter(x=>x.workflow_active===true).length;return active===0?'legacy':active===rows.length?'workflow':'mixed';}
function allWorkflowSelected(){return selectedRows().length>0&&selectionMode(selectedRows())==='workflow'}
function invalidatePreview(){state.previewGeneration=(state.previewGeneration||0)+1;state.preview=null;state.previewPayload=null;state.previewFingerprint=null;$('previewPanel').classList.add('d-none');$('settleBtn').disabled=true;}
function syncWorkflowControls(){const mode=selectionMode(selectedRows()),active=mode!=='legacy'||selectedRows().some(x=>x.can_change_billing===false);$('workflowLineModeHost').classList.toggle('d-none',mode!=='workflow');for(const id of ['settlementMethod','prepaidCard','agreementId','setMethodBtn']){$(id).disabled=active;if(active&&id!=='setMethodBtn')$(id).value='';}$('approveBtn').disabled=!selectedRows().length||selectedRows().some(x=>x.can_approve!==true);$('stickyApproveBtn').disabled=$('approveBtn').disabled;const allApproved=selectedRows().length>0&&selectedRows().every(x=>x.lifecycle_status==='approved');$('previewBtn').innerHTML=allApproved?'Send til fakturering / afregn…':'Godkend og send / afregn…';$('stickyPreviewBtn').textContent=allApproved?'Send til fakturering / afregn…':'Godkend og send / afregn…';$('previewBtn').disabled=mode==='mixed'||!selectedRows().length||selectedRows().some(x=>x.can_settle!==true);$('stickyPreviewBtn').disabled=$('previewBtn').disabled;$('selectionText').textContent=mode==='mixed'?'Blandet udvalg: vælg workflowtider og øvrige tider hver for sig.':`${ids().length} tidsregistreringer valgt`;return active;}
function payload(){return {ids:ids(),line_mode:$('workflowLineMode').value||'separate'};}
function fingerprint(){return JSON.stringify({generation:state.previewGeneration||0,request:payload(),method:$('settlementMethod').value,card:$('prepaidCard').value,agreement:$('agreementId').value,line:$('workflowLineMode').value,triage:state.triage,filters:['filterMethod','filterCustomer','filterStatus','filterCaseState','filterBillable','filterQuery'].map(id=>$(id).value)});}
function lifecycle(row){return `<spanclass="badge ${['approved','invoiced','settled'].includes(row.lifecycle_status)?'text-bg-success':row.lifecycle_status==='sent'?'text-bg-info':'text-bg-warning'}">${esc(row.lifecycle_label||labels[row.status]||row.status||'Afventer godkendelse')}</span>`;}
function status(v){return `<spanclass="badge ${v==='approved'?'text-bg-success':v==='rejected'?'text-bg-danger':'text-bg-warning'}">${esc(labels[v]||v||'Ukendt')}</span>`} function method(v){const m=norm(v),cls='queue-method-badge';return `<spanclass="badge ${cls}">${esc(labels[m]||m)}</span>`}
function selectedRows(){return state.items.filter(x=>state.selected.has(Number(x.id)))}
function selectedCardCustomerIds(){return [...new Set(selectedRows().map(x=>Number(x.billing_customer_id||x.hub_customer_id||0)).filter(Boolean))]}
function selectedHours(){return selectedRows().reduce((total,x)=>total+Number(x.approved_hours||x.original_hours||0),0)}
function actualHours(rows){return rows.reduce((total,x)=>total+Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)/60,0)}
function cardHours(rows,block){const safeBlock=Math.max(1,Number(block)||30);return rows.reduce((total,x)=>{const minutes=Math.max(0,Math.round(Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)));return total+(minutes?Math.ceil(minutes/safeBlock)*safeBlock/60:0)},0)}
function renderPrepaidHelp(){const selected=selectedRows(),customers=selectedCardCustomerIds(),card=state.cards.find(x=>Number(x.id)===Number($('prepaidCard').value)),storedHours=selectedHours();if(!selected.length){$('prepaidHelp').textContent='Vælg tidsregistreringer for at se kundens klippekort.';return}if(customers.length!==1){$('prepaidHelp').innerHTML='<spanclass="text-danger">Vælg kun tider fra ét firma for at bruge ét klippekort.</span>';return}const company=selected[0].customer_name||'den valgte kunde';if(!card){$('prepaidHelp').textContent=`${esc(company)} · Fakturerbar tid: ${h(storedHours)} timer. Vælg et klippekort for at se kortets afrunding.`;return}const block=Number(card.rounding_minutes)||30,actual=actualHours(selected),hours=cardHours(selected,block),remaining=Number(card.remaining_hours||0),after=remaining-hours;$('prepaidHelp').innerHTML=`${esc(company)} · Faktisk tid: <strong>${h(actual)} t</strong> → klippes som <strong>${h(hours)} t</strong><spanclass="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strongclass="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`}
function url(){const p=new URLSearchParams({limit:'500',scope:state.scope});if($('filterCaseState').value)p.set('case_state',$('filterCaseState').value);if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`}
async function loadCustomers(){try{const d=await api('/api/v1/economy/time-queue/customers'),v=$('filterCustomer').value;$('filterCustomer').innerHTML=`<optionvalue="">Alle firmaer</option>${(d.items||[]).map(x=>`<optionvalue="${Number(x.customer_id)}">${esc(x.customer_name)} (${Number(x.open_count||0)})</option>`).join('')}`;$('filterCustomer').value=v}catch(_){message('warning','Firmafilteret kunne ikke hentes.') }}
async function loadCards(){const selectionKey=ids().join(',');if(selectionMode(selectedRows())!=='legacy'){$('prepaidHelp').textContent='Afregningen følger workflowbeslutningerne.';return;}const select=$('prepaidCard'),customers=selectedCardCustomerIds(),current=Number(select.value);state.cards=[];select.disabled=true;if(customers.length!==1){select.innerHTML=`<optionvalue="">${customers.length>1?'Vælg tider fra ét firma':'Vælg først tider fra ét firma'}</option>`;renderPrepaidHelp();return}try{const d=await api(`/api/v1/economy/time-queue/prepaid-cards?customer_id=${customers[0]}`);if(selectionKey!==ids().join(',')||selectionMode(selectedRows())!=='legacy')return;state.cards=(d.items||[]).filter(x=>x.status==='active'&&Number(x.remaining_hours)>0);select.innerHTML=`<optionvalue="">Vælg kundens klippekort</option>${state.cards.map(x=>`<optionvalue="${Number(x.id)}">${esc(x.card_number)} · ${h(x.remaining_hours)} timer tilbage</option>`).join('')}`;select.disabled=!state.cards.length;if(state.cards.some(x=>Number(x.id)===current))select.value=String(current);renderPrepaidHelp()}catch(_){select.innerHTML='<optionvalue="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}}
async function approveSelected(){const rows=selectedRows();if(!rows.length||rows.some(x=>!x.can_approve))return message('warning','Vælg tider, der afventer godkendelse.');invalidatePreview();$('approveBtn').disabled=true;$('stickyApproveBtn').disabled=true;try{await api('/api/v1/economy/time-queue/approve-reviewed',{method:'POST',body:JSON.stringify({ids:ids(),snapshots:rows.map(approvalSnapshot)})});message('success','Tiderne er godkendt. Ingen timer er trukket, og ingen ordre er oprettet.');state.selected.clear();await load()}catch(e){message('danger',e.message);render()}}
async function changeBilling(id){const row=state.items.find(x=>Number(x.id)===id);if(!row?.can_change_billing||state.scope==='history')return;invalidatePreview();try{if(row.workflow_active){if(await TimeBillingReview.choose(row))await load();return;}const options=await api(`/api/v1/timetracking/time/${id}/settlement-options`),host=document.createElement('div');host.className='modal fade';host.tabIndex=-1;host.innerHTML='<divclass="modal-dialog"><formclass="modal-content"><divclass="modal-header"><h2class="modal-title h5">Ændr afregning</h2><buttonclass="btn-close"type="button"data-bs-dismiss="modal"aria-label="Luk"></button></div><divclass="modal-body"><labelclass="form-label">Afregning</label><selectclass="form-select"required></select><pclass="small text-muted mt-2">Valget gemmes. Tiden godkendes først, når du vælger Godkend.</p><divclass="text-danger small"role="status"></div></div><divclass="modal-footer"><buttontype="button"class="btn btn-secondary"data-bs-dismiss="modal">Annuller</button><buttontype="submit"class="btn btn-primary">Gem afregning</button></div></form></div>';const select=host.querySelector('select');for(const [value,label] of [['invoice','Faktura'],['internal','Intern tid'],['non_billable','Ikke fakturerbar']])select.add(new Option(label,value));(options.prepaid_cards||[]).forEach(c=>select.add(new Option('Klippekort '+(c.card_number||c.id),'card_'+c.id)));(options.agreements||[]).forEach(a=>select.add(new Option('Fastpris '+(a.agreement_number||a.id),'fpa_'+a.id)));select.value=effectiveMethod(row)==='prepaid'?'card_'+row.prepaid_card_id:effectiveMethod(row)==='subscription'?'fpa_'+row.fixed_price_agreement_id:effectiveMethod(row);document.body.append(host);const modal=new bootstrap.Modal(host);host.addEventListener('hidden.bs.modal',()=>{modal.dispose();host.remove()},{once:true});host.querySelector('form').onsubmit=async e=>{e.preventDefault();const value=select.value,body={ids:[id],billing_method:value,prepaid_card_id:null,fixed_price_agreement_id:null};if(value.startsWith('card_')){body.billing_method='prepaid';body.prepaid_card_id=Number(value.slice(5));}if(value.startsWith('fpa_')){body.billing_method='subscription';body.fixed_price_agreement_id=Number(value.slice(4));}body.billable=['invoice','prepaid'].includes(body.billing_method);const button=host.querySelector('[type=submit]');button.disabled=true;try{await api('/api/v1/economy/time-queue/bulk-update',{method:'PATCH',body:JSON.stringify(body)});modal.hide();await load()}catch(error){host.querySelector('[role=status]').textContent=error.message;button.disabled=false;}};modal.show();}catch(e){message('danger',e.message)}}
async function saveMethod(){if(selectedRows().some(x=>x.can_change_billing===false))return message('warning','Godkendt eller afregnet tid kan ikke ændres her.');if(selectionMode(selectedRows())!=='legacy')return message('warning','Brug Hvorfor? for en begrundet overstyring.');if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');const m=$('settlementMethod').value;if(!m)return message('warning','Vælg en afregningstype, der skal gemmes.');try{await api('/api/v1/economy/time-queue/bulk-update',{method:'PATCH',body:JSON.stringify({ids:ids(),billing_method:m,billable:['invoice','prepaid'].includes(m)})});message('success','Afregningstypen er gemt.');$('settlementMethod').value='';$('prepaidCard').value='';$('agreementId').value='';await load()}catch(e){message('danger',e.message)}}
$('previewBody').innerHTML=`<divclass="alert alert-info py-2"><strong>Hvad gør forhåndsvisning?</strong> Den kontrollerer valgte registreringer, kunde, klippekort/aftale, timer og beløb. Den gemmer ikke noget og trækker ikke timer. Det sker først, når du godkender og gennemfører nedenfor.</div>${errors.length?`<divclass="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ulclass="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<divclass="alert alert-success"><iclass="bi bi-check-circle me-1"></i>Alle valgte tider kan afregnes. Løst/lukket sagsstatus blokerer ikke.</div>'}<divclass="row g-3">${invoice.map(x=>`<divclass="col-12 col-lg-6"><divclass="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><divclass="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><divclass="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><divclass="small text-muted">Gebyr og evt. fragt vises i ordrekladde.</div></div></div>`).join('')}${prepaid.map(x=>`<divclass="col-12 col-lg-6"><divclass="preview-group p-3 rounded bg-light"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><divclass="small text-muted">Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t · efter: ${h(Number(x.remaining_hours)-Number(x.hours))} t</div><divclass="small text-muted">Afrunding: ${Number(x.rounding_minutes)||30} min. pr. registrering</div></div></div>`).join('')}${sub.map(x=>`<divclass="col-12 col-lg-6"><divclass="preview-group p-3 rounded bg-light"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><divclass="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden særskilt fakturalinje.</div></div></div>`).join('')}</div><h3class="h6 mt-4">Valgte tidsregistreringer (${items.length})</h3><divclass="preview-items">${itemRows||'<divclass="text-muted">Ingen registreringer.</div>'}</div>`;
const onlyInvoice=items.length>0&&items.every(x=>norm(x.method)==='invoice');const approved=selectedRows().every(x=>x.lifecycle_status==='approved');$('settleBtn').innerHTML=onlyInvoice?(approved?'Send til fakturering':'Godkend og send til fakturering'):(approved?'Afregn':'Godkend og afregn');
async function preview(){if(selectedRows().some(x=>x.can_settle!==true))return message('warning','Disse tider kan ikke sendes eller afregnes.');if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');if(selectionMode(selectedRows())==='mixed')return message('warning','Vælg workflowtider og øvrige tider hver for sig.');invalidatePreview();const request=payload(),key=fingerprint();try{const d=await api('/api/v1/economy/time-queue/preview-settlement',{method:'POST',body:JSON.stringify(request)});if(key!==fingerprint())return;state.previewPayload=JSON.parse(JSON.stringify({...request,snapshots:selectedRows().map(approvalSnapshot)}));state.previewFingerprint=key;previewView(d)}catch(e){message('danger',e.message)}}
async function settle(){if(!state.preview?.valid||!state.previewPayload)return;if(state.previewFingerprint!==fingerprint()){invalidatePreview();return message('warning','Udvalget eller afregningen er ændret. Lav en ny forhåndsvisning.');}const request=state.previewPayload;if(!confirm('Gennemfør den viste afregning? Fakturatid sendes til en lokal ordrekladde. Klippekort trækker timer, og øvrig tid afregnes som vist.'))return;$('settleBtn').disabled=true;$('settleBtn').innerHTML='<spanclass="spinner-border spinner-border-sm me-1"></span>Afregner…';try{const d=await api('/api/v1/economy/time-queue/send-reviewed',{method:'POST',body:JSON.stringify(request)});message('success',`Afregningen er gennemført.${d.created_drafts?.length?' Ordrekladder er oprettet.':''}`);state.selected.clear();invalidatePreview();if(d.orders_url)$('messageArea').insertAdjacentHTML('beforeend',`<p><aclass="btn btn-outline-primary"href="${esc(d.orders_url)}">Åbn ordrekladde</a></p>`);await loadCustomers();await load()}catch(e){message('danger',e.message);invalidatePreview()}finally{$('settleBtn').disabled=!state.preview?.valid;$('settleBtn').innerHTML='<iclass="bi bi-check2-circle me-1"></i>Godkend og gennemfør'}}