76 lines
8.5 KiB
JavaScript
76 lines
8.5 KiB
JavaScript
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};
|
||
})();
|