bmc_hub/static/js/time-billing-review.js

76 lines
8.5 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.

window.TimeBillingReview = (() => {
const labels={draft:'Kladde',operations:'Drift',development:'Udvikling'};
const needsReview=entry=>Object.hasOwn(labels,entry.billing_method || entry._billing_method || '');
const badge=entry=>needsReview(entry)?`<span class="badge text-bg-warning">${labels[entry.billing_method || entry._billing_method]} · Chefkontrol</span>`:'';
const methods={invoice:'Faktura',prepaid:'Klippekort',subscription:'Fastpris',fixed_price:'Fastpris',internal:'Intern tid',non_billable:'Ikke fakturerbar',draft:'Kladde',operations:'Drift',development:'Udvikling'};
const settled=entry=>entry.status==='billed'||(entry.workflow_active===true&&entry.status==='approved')||!!entry.economy_settled_at||!!entry.economy_order_draft_id||!!entry.billed_via_thehub_id;
const canChoose=entry=>!settled(entry)&&(entry.workflow_active===true?entry.workflow_can_override===true:needsReview(entry));
const displayBadge=entry=>{
const method=entry.selected_billing_method||entry.workflow_decision?.method||entry.billing_method||'draft';
const text=method==='manual_review'?'Manuel vurdering':methods[method]||'Afregning afventer';
return `<span class="badge text-bg-secondary">${text}${entry.workflow_stale?' · skal genberegnes':''}</span>`;
};
async function choose(entry) {
if(!canChoose(entry))throw new Error('Afregningen kan ikke ændres for denne tidsregistrering.');
if(entry.workflow_active!==true)return approve(entry);
const optionsResponse=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/settlement-options`,{credentials:'include'});
if(!optionsResponse.ok)throw new Error('Afregningsmuligheder kunne ikke hentes');
const data=await optionsResponse.json();
const decisionResponse=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/workflow-decision`,{credentials:'include'});
if(!decisionResponse.ok)throw new Error('Afregningsbeslutningen kunne ikke hentes');
const history=await decisionResponse.json(),current=history.current;
if(current?.status==='settled')throw new Error('Afregnet tid kræver en særskilt korrektion.');
const host=document.createElement('div');host.className='modal fade';host.tabIndex=-1;
host.innerHTML='<div class="modal-dialog"><form class="modal-content"><div class="modal-header"><h5 class="modal-title">Vælg afregning</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body"><p class="small text-muted">Valget gemmes med begrundelse. Bogholderiet godkender og afregner tiden.</p><label class="form-label">Afregning</label><select class="form-select" required></select><label class="form-label mt-3">Begrundelse</label><textarea class="form-control" minlength="3" maxlength="2000" required></textarea><div class="small mt-2 text-danger" role="status"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary">Gem afregningsvalg</button></div></form></div>';
const select=host.querySelector('select');select.add(new Option('Faktura','invoice'));select.add(new Option('Intern tid','internal'));select.add(new Option('Ikke fakturerbar','non_billable'));
(data.prepaid_cards||[]).forEach(c=>select.add(new Option('Klippekort '+(c.card_number||c.id),'card_'+c.id)));
(data.agreements||[]).forEach(a=>select.add(new Option('Fastpris '+(a.agreement_number||a.id),'fpa_'+a.id)));
const details=current?.details||{},billing=details.billing||details.decision||{};
select.value=current?.method==='prepaid'?'card_'+billing.card_id:current?.method==='subscription'?'fpa_'+billing.agreement_id:current?.method||entry.selected_billing_method||'invoice';
if(!select.value)select.value='invoice';
document.body.append(host);const modal=new bootstrap.Modal(host);
return new Promise(resolve=>{
let changed=false;
host.addEventListener('hidden.bs.modal',()=>{modal.dispose();host.remove();resolve(changed);},{once:true});
host.querySelector('form').onsubmit=async event=>{
event.preventDefault();const reason=host.querySelector('textarea').value.trim();
if(reason.length<3){host.querySelector('[role=status]').textContent='Angiv en kort begrundelse.';return;}
const value=select.value,payload={method:value,reason},button=host.querySelector('[type=submit]');button.disabled=true;
if(value.startsWith('card_')){payload.method='prepaid';payload.card_id=Number(value.slice(5));}
if(value.startsWith('fpa_')){payload.method='subscription';payload.agreement_id=Number(value.slice(4));}
if(billing.product_number)payload.product_number=billing.product_number;
try{const response=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/workflow-override`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const result=await response.json().catch(()=>({}));if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Afregningsvalget kunne ikke gemmes');changed=true;modal.hide();}catch(error){host.querySelector('[role=status]').textContent=error.message;button.disabled=false;}
};modal.show();
});
}
async function approve(entry,hours) {
const response=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/settlement-options`,{credentials:'include'});
if(!response.ok)throw new Error('Afregningsmuligheder kunne ikke hentes');
const data=await response.json();
const host=document.createElement('div');host.className='modal fade';host.tabIndex=-1;
host.innerHTML='<div class="modal-dialog"><form class="modal-content"><div class="modal-header"><h5 class="modal-title">Chefkontrol – vælg afregning</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body"><p data-label></p><label class="form-label">Endelig afregning</label><select class="form-select" required><option value="">Vælg afregning…</option></select><div class="small mt-3" role="status"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-success">Godkend</button></div></form></div>';
host.querySelector('[data-label]').textContent=labels[entry.billing_method || entry._billing_method]+' · '+(entry.description || entry.beskrivelse || 'Tidsregistrering');
const select=host.querySelector('select');select.add(new Option('Faktura','invoice'));
(data.prepaid_cards||[]).forEach(c=>select.add(new Option(`Klippekort #${c.card_number || c.id}`,`card_${c.id}`)));
(data.agreements||[]).forEach(a=>select.add(new Option(`Fastpris #${a.agreement_number || a.id}`,`fpa_${a.id}`)));
document.body.append(host);const modal=new bootstrap.Modal(host);
return new Promise(resolve=>{
let approved=false;
host.addEventListener('hidden.bs.modal',()=>{modal.dispose();host.remove();resolve(approved);},{once:true});
host.querySelector('form').onsubmit=async event=>{
event.preventDefault();const button=host.querySelector('[type=submit]');button.disabled=true;
const value=select.value,payload={billing_method:value};
if(value.startsWith('card_')){payload.billing_method='prepaid';payload.prepaid_card_id=Number(value.slice(5));}
if(value.startsWith('fpa_')){payload.billing_method='subscription';payload.fixed_price_agreement_id=Number(value.slice(4));}
if(Number.isFinite(Number(hours))&&Number(hours)>0)payload.fakturerbar_tid_min=Math.round(Number(hours)*60);
try {
const result=await fetch(`/api/v1/timetracking/time/${Number(entry.id)}/approve`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const body=await result.json().catch(()=>({}));if(!result.ok)throw new Error(body.detail || 'Kunne ikke godkende');
approved=true;modal.hide();
}catch(error){host.querySelector('[role=status]').textContent=error.message;button.disabled=false;}
};modal.show();
});
}
return {needsReview,badge,approve,choose,canChoose,displayBadge};
})();