<sectionclass="card mb-3 d-none"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-primary"id="previewBtn"><iclass="bi bi-eye me-1"></i>Forhåndsvis</button></div><detailsclass="queue-details"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-3 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">Workflowvalg ændres under Hvorfor?</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. Først når du trykker “Gennemfør”, gemmes afregningen.</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>Gennemfør afregning</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 effectiveMethod(row){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'].includes(d.status))return 'draft';return norm(d.method);}return norm(row.billing_method);}
function reviewIssue(row){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';$('workflowLineModeHost').classList.toggle('d-none',mode!=='workflow');for(const id of ['settlementMethod','prepaidCard','agreementId','setMethodBtn']){$(id).disabled=active;if(active&&id!=='setMethodBtn')$(id).value='';}$('previewBtn').disabled=mode==='mixed';$('stickyPreviewBtn').disabled=mode==='mixed';$('selectionText').textContent=mode==='mixed'?'Blandet udvalg: vælg workflowtider og øvrige tider hver for sig.':`${ids().length} tidsregistreringer valgt`;return active;}
function payload(){if(allWorkflowSelected())return {ids:ids(),line_mode:$('workflowLineMode').value};const method=$('settlementMethod').value,card=Number($('prepaidCard').value),agreement=Number($('agreementId').value);return {ids:ids(),...(method?{billing_method:method}:{}),...(card?{prepaid_card_id:card}:{}),...(agreement?{fixed_price_agreement_id:agreement}:{})}}
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 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'});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 saveMethod(){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.');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 efter “Gennemfør afregning”.</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>`;
async function preview(){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));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;$('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/settle',{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>Gennemfør afregning'}}
function selectionChanged(){invalidatePreview();render();loadCards()}