<divclass="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><divclass="text-primary text-uppercase fw-semibold small">Økonomi</div><h1class="h2 mb-1">Tidskø til afregning</h1><pclass="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><buttonclass="btn btn-outline-secondary"id="reloadBtn"><iclass="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<divid="messageArea"aria-live="polite"></div>
<sectionclass="queue-summary p-3 mb-3"><divclass="d-flex flex-wrap gap-4 align-items-center"><divclass="queue-stat"><divclass="small text-muted">Registreringer</div><strongid="statEntries">–</strong></div><divclass="queue-stat"><divclass="small text-muted">Timer i alt</div><strongid="statHours">–</strong></div><divclass="queue-stat"><divclass="small text-muted">Klar til faktura</div><strongid="statInvoice">–</strong></div><divclass="queue-stat"><divclass="small text-muted">Udvalgt</div><strongid="statSelected">0</strong></div><divclass="ms-md-auto small text-muted"><iclass="bi bi-shield-check text-success me-1"></i>Intet afregnes uden forhåndsvisning.</div></div></section>
<sectionclass="card mb-3"><divclass="card-body"><divclass="row g-2 align-items-end"><divclass="col-12 col-md-3"><labelclass="form-label">Firma</label><selectid="filterCustomer"class="form-select"><optionvalue="">Alle firmaer</option></select></div><divclass="col-6 col-md-2"><labelclass="form-label">Status</label><selectid="filterStatus"class="form-select"><optionvalue="">Alle statusser</option><optionvalue="pending">Afventer</option><optionvalue="approved">Godkendt</option><optionvalue="rejected">Afvist</option></select></div><divclass="col-6 col-md-2"><labelclass="form-label">Fakturerbar</label><selectid="filterBillable"class="form-select"><optionvalue="">Alle</option><optionvalue="true">Ja</option><optionvalue="false">Nej</option></select></div><divclass="col-12 col-md-4"><labelclass="form-label">Søg</label><inputid="filterQuery"class="form-control"placeholder="Kunde, sag eller beskrivelse"></div><divclass="col-12 col-md-1 d-grid"><buttonclass="btn btn-outline-secondary"id="clearFiltersBtn">Ryd</button></div></div></div></section>
<sectionclass="card mb-3"><divclass="card-body"><divclass="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><divclass="small text-muted">Vælg type og eventuelt klippekort eller aftale.</div></div><spanclass="badge text-bg-primary align-self-center"id="selectedBadge">0 valgt</span></div><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><buttonclass="btn btn-primary flex-grow-1"id="previewBtn"><iclass="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
<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">Kontrollér beløb og fejl før afregningen gennemføres.</div></div><buttonclass="btn btn-sm btn-outline-secondary"id="closePreviewBtn">Luk</button></div><divclass="card-body"id="previewBody"></div><divclass="card-footer text-end"><buttonclass="btn btn-success"id="settleBtn"><iclass="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></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 payload(){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 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=m==='invoice'?'text-bg-primary':m==='prepaid'?'text-bg-purple':m==='subscription'?'text-bg-success':'text-bg-secondary';return `<spanclass="badge ${cls}">${esc(labels[m]||m)}</span>`}
function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)}
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($('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 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]}`);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(!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)}}
function previewView(d){state.preview=d;$('previewPanel').classList.remove('d-none');const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[];$('previewBody').innerHTML=`${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 er klar til afregning.</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 ordrekladden.</div></div></div>`).join('')}${prepaid.map(x=>`<divclass="col-12 col-lg-6"><divclass="preview-group p-3 rounded bg-light"style="border-left-color:#6f42c1"><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</div></div></div>`).join('')}${sub.map(x=>`<divclass="col-12 col-lg-6"><divclass="preview-group p-3 rounded bg-light"style="border-left-color:#198754"><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 fakturalinje.</div></div></div>`).join('')}</div>`;$('settleBtn').disabled=!d.valid;$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'})}
async function preview(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');try{previewView(await api('/api/v1/economy/time-queue/preview-settlement',{method:'POST',body:JSON.stringify(payload())}))}catch(e){message('danger',e.message)}}
async function settle(){if(!state.preview?.valid)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/settle',{method:'POST',body:JSON.stringify(payload())});message('success',`Afregningen er gennemført.${d.created_drafts?.length?' Ordrekladder er oprettet.':''}`);state.selected.clear();$('previewPanel').classList.add('d-none');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);if(e.payload?.detail?.preview)previewView(e.payload.detail.preview)}finally{$('settleBtn').disabled=false;$('settleBtn').innerHTML='<iclass="bi bi-check2-circle me-1"></i>Gennemfør afregning'}}
function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()}