Make economy time queue actionable and show case tags

This commit is contained in:
Christian 2026-10-09 00:45:21 +02:00
parent 8012423844
commit cdb9d8c178
7 changed files with 172 additions and 21 deletions

View File

@ -225,6 +225,7 @@ async def list_hub_time_queue(
params.append(limit) params.append(limit)
rows = execute_query(query, tuple(params)) rows = execute_query(query, tuple(params))
_attach_case_tags(rows)
if settings.WORKFLOWS_ENABLED: if settings.WORKFLOWS_ENABLED:
_attach_workflow_decisions(rows) _attach_workflow_decisions(rows)
return {"items": rows, "count": len(rows), "workflow_enabled": settings.WORKFLOWS_ENABLED} return {"items": rows, "count": len(rows), "workflow_enabled": settings.WORKFLOWS_ENABLED}
@ -1230,6 +1231,26 @@ def _protect_workflow_selection(ids):
raise HTTPException(409, 'Brug workflowbeslutningens begrundede overstyring og afregning') raise HTTPException(409, 'Brug workflowbeslutningens begrundede overstyring og afregning')
def _attach_case_tags(rows):
"""Load tags once for all visible cases, including legacy case-only tags."""
case_ids = sorted({int(row['sag_id']) for row in rows if row.get('sag_id')})
by_case = {}
if case_ids:
tags = execute_query('''SELECT et.entity_id AS sag_id,t.id,t.name,t.color,t.icon
FROM entity_tags et JOIN tags t ON t.id=et.tag_id
WHERE et.entity_type='case' AND et.entity_id=ANY(%s)
ORDER BY t.type,t.name,t.id''', (case_ids,)) or []
for tag in tags:
by_case.setdefault(int(tag['sag_id']), []).append({key: tag.get(key) for key in ('id','name','color','icon')})
missing = [case_id for case_id in case_ids if case_id not in by_case]
if missing:
legacy = execute_query('SELECT sag_id,id,tag_navn AS name FROM sag_tags WHERE sag_id=ANY(%s) AND deleted_at IS NULL ORDER BY tag_navn,id', (missing,)) or []
for tag in legacy:
by_case.setdefault(int(tag['sag_id']), []).append({'id':tag['id'],'name':tag['name'],'color':None,'icon':None})
for row in rows:
row['case_tags'] = by_case.get(int(row['sag_id']), []) if row.get('sag_id') else []
def _attach_workflow_decisions(rows): def _attach_workflow_decisions(rows):
if not rows: if not rows:
return return
@ -1238,10 +1259,14 @@ def _attach_workflow_decisions(rows):
decisions=execute_query('''SELECT d.*,t.workflow_revision AS entry_revision FROM bmc_billing_decisions d decisions=execute_query('''SELECT d.*,t.workflow_revision AS entry_revision FROM bmc_billing_decisions d
JOIN tmodule_times t ON t.id=d.time_id WHERE d.time_id=ANY(%s) AND d.is_current''',(ids,)) or [] JOIN tmodule_times t ON t.id=d.time_id WHERE d.time_id=ANY(%s) AND d.is_current''',(ids,)) or []
by_id={int(d['time_id']):d for d in decisions} by_id={int(d['time_id']):d for d in decisions}
customer_activation = {}
for row in rows: for row in rows:
decision=by_id.get(int(row['id'])) decision=by_id.get(int(row['id']))
row['workflow_decision']=decision row['workflow_decision']=decision
row['workflow_active']=active_for_customer(row.get('billing_customer_id') or row.get('customer_id')) customer_id = row.get('billing_customer_id') or row.get('customer_id')
if customer_id not in customer_activation:
customer_activation[customer_id] = active_for_customer(customer_id)
row['workflow_active']=customer_activation[customer_id]
row['workflow_stale']=bool(decision and decision['revision']!=decision['entry_revision']) row['workflow_stale']=bool(decision and decision['revision']!=decision['entry_revision'])
if row['workflow_active'] and decision and decision.get('method'): if row['workflow_active'] and decision and decision.get('method'):
row['billing_method']=decision['method'] row['billing_method']=decision['method']

View File

@ -14,7 +14,7 @@
.time-row .small { font-size:.75rem;line-height:1.15; }.time-row .fw-semibold,.time-row strong { font-size:.85rem;line-height:1.15; } .time-row .small { font-size:.75rem;line-height:1.15; }.time-row .fw-semibold,.time-row strong { font-size:.85rem;line-height:1.15; }
.time-row .col-6 .small.text-muted { display:none; }.time-row .col-8 .small.text-muted { display:none; } .time-row .col-6 .small.text-muted { display:none; }.time-row .col-8 .small.text-muted { display:none; }
.time-row .col-12.col-lg-4,.time-row .col-6,.time-row .col-8 { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; } .time-row .col-12.col-lg-4,.time-row .col-6,.time-row .col-8 { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; }
.time-row > .mt-2 { display:none; }.time-row .btn { padding:.18rem .48rem;font-size:.75rem; } .time-row > .mt-2 { display:block; margin-top:.2rem!important; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }.time-row > .queue-tags { white-space:normal; }.time-row .btn { padding:.18rem .48rem;font-size:.75rem; }
@media (max-width:1100px){.time-row .row{grid-template-columns:22px minmax(240px,2fr) minmax(115px,1fr) minmax(92px,.6fr) 78px}.time-row .col-8.col-lg-2{display:none}} @media (max-width:1100px){.time-row .row{grid-template-columns:22px minmax(240px,2fr) minmax(115px,1fr) minmax(92px,.6fr) 78px}.time-row .col-8.col-lg-2{display:none}}
@media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}} @media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}}
.queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem} .queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem}
@ -24,39 +24,49 @@
<main class="container-fluid py-4 time-queue"> <main class="container-fluid py-4 time-queue">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary text-uppercase fw-semibold small">Økonomi</div><h1 class="h2 mb-1">Tidskø til afregning</h1><p class="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><button class="btn btn-outline-secondary" id="reloadBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div> <div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary text-uppercase fw-semibold small">Økonomi</div><h1 class="h2 mb-1">Tidskø til afregning</h1><p class="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><button class="btn btn-outline-secondary" id="reloadBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<div id="messageArea" aria-live="polite"></div> <div id="messageArea" aria-live="polite"></div>
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Sager løst/lukket</div><strong id="statClosedCases">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Forhåndsvisning kontrollerer; den ændrer ikke data.</div></div></section> <section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Faktura i køen</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Sager løst/lukket</div><strong id="statClosedCases">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Forhåndsvisning kontrollerer; den ændrer ikke data.</div></div></section>
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Tidsstatus</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Sagens status</label><select id="filterCaseState" class="form-select"><option value="">Alle sager (inkl. løst/lukket)</option><option value="closed">Løst eller lukket</option><option value="open">Åbne sager</option><option value="no_case">Uden sag</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-2"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section> <section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Tidsstatus</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Sagens status</label><select id="filterCaseState" class="form-select"><option value="">Alle sager (inkl. løst/lukket)</option><option value="closed">Løst eller lukket</option><option value="open">Åbne sager</option><option value="no_case">Uden sag</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-2"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale. Løst/lukket sagsstatus forhindrer ikke afregning.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section> <section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale. Løst/lukket sagsstatus forhindrer ikke afregning.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
<div class="mb-3"><label class="form-label" for="filterMethod">Afregningsmetode</label><select id="filterMethod" class="form-select" style="max-width:300px"><option value="">Alle metoder</option><option value="invoice">Faktura</option><option value="subscription">Fastpris</option><option value="prepaid">Klippekort</option><option value="internal">Intern tid</option><option value="non_billable">Ikke fakturerbar</option><option value="manual_review">Manuel vurdering</option></select></div><div id="workflowLineModeHost" class="mb-3 d-none"><label class="form-label">Fakturalinjer<select id="workflowLineMode" class="form-select"><option value="separate">En linje pr. tidsregistrering</option><option value="combined">Saml ens faktureringsvilkår</option></select></label><p class="small text-muted">Afregningsmetoden er valgt af workflows. Brug Hvorfor? for at overstyre med begrundelse.</p></div><section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Dette er en kontrol uden ændringer. Først når du trykker “Gennemfør”, gemmes afregningen.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer"><div class="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><div class="text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></div></section> <div class="mb-3"><label class="form-label" for="filterMethod">Afregningsmetode</label><select id="filterMethod" class="form-select" style="max-width:300px"><option value="">Alle metoder</option><option value="invoice">Faktura</option><option value="subscription">Fastpris</option><option value="prepaid">Klippekort</option><option value="internal">Intern tid</option><option value="non_billable">Ikke fakturerbar</option><option value="draft">Kladde</option><option value="operations">Drift</option><option value="development">Udvikling</option><option value="manual_review">Manuel vurdering</option></select></div><div id="workflowLineModeHost" class="mb-3 d-none"><label class="form-label">Fakturalinjer<select id="workflowLineMode" class="form-select"><option value="separate">En linje pr. tidsregistrering</option><option value="combined">Saml ens faktureringsvilkår</option></select></label><p class="small text-muted">Afregningsmetoden er valgt af workflows. Brug Hvorfor? for at overstyre med begrundelse.</p></div><section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Dette er en kontrol uden ændringer. Først når du trykker “Gennemfør”, gemmes afregningen.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer"><div class="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><div class="text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></div></section>
<div class="modal fade" id="caseDetailModal" tabindex="-1" aria-labelledby="caseDetailTitle" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><div class="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2 class="modal-title h4 mb-0" id="caseDetailTitle">Sagsdetaljer</h2></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body" id="caseDetailBody"></div><div class="modal-footer"><a class="btn btn-outline-primary" id="openCaseNewTab" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div></div></div></div> <div class="modal fade" id="caseDetailModal" tabindex="-1" aria-labelledby="caseDetailTitle" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><div class="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2 class="modal-title h4 mb-0" id="caseDetailTitle">Sagsdetaljer</h2></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body" id="caseDetailBody"></div><div class="modal-footer"><a class="btn btn-outline-primary" id="openCaseNewTab" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div></div></div></div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-3"><div class="btn-group" role="group" aria-label="Gennemgang"><button class="btn btn-primary triage-tab" data-triage="all">Alle <span id="countAll">0</span></button><button class="btn btn-outline-primary triage-tab" data-triage="ready">Klar til gennemgang <span id="countReady">0</span></button><button class="btn btn-outline-primary triage-tab" data-triage="action">Kræver handling <span id="countAction">0</span></button></div><button class="btn btn-outline-primary btn-sm" id="selectReadyBtn">Vælg synlige klar til gennemgang</button><span class="small text-muted">Foreløbig vurdering. Forhåndsvisning kontrollerer endeligt.</span></div>
<section id="queueContent" aria-live="polite"></section> <section id="queueContent" aria-live="polite"></section>
<div class="queue-actions card d-none" id="selectionActions"><div class="card-body py-2 d-flex flex-wrap justify-content-between align-items-center gap-2"><div><strong id="selectionText">0 tidsregistreringer valgt</strong><span class="small text-muted ms-2">Vælg forhåndsvisning før afregning.</span></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Fravælg</button><button class="btn btn-sm btn-primary" id="stickyPreviewBtn">Forhåndsvis afregning</button></div></div></div> <div class="queue-actions card d-none" id="selectionActions"><div class="card-body py-2 d-flex flex-wrap justify-content-between align-items-center gap-2"><div><strong id="selectionText">0 tidsregistreringer valgt</strong><span class="small text-muted ms-2">Vælg forhåndsvisning før afregning.</span></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Fravælg</button><button class="btn btn-sm btn-primary" id="stickyPreviewBtn">Forhåndsvis afregning</button></div></div></div>
</main> </main>
<script> <script>
(() => { (() => {
const state={items:[],selected:new Set(),preview:null,loading:false,cards:[],workflowEnabled:false}; const $=id=>document.getElementById(id); const state={items:[],selected:new Set(),preview:null,loading:false,cards:[],workflowEnabled:false,triage:'all',previewPayload:null,previewFingerprint:null}; const $=id=>document.getElementById(id);
const labels={pending:'Afventer',approved:'Godkendt',rejected:'Afvist',invoice:'Faktura',prepaid:'Klippekort',subscription:'Abonnement / fast pris',internal:'Intern tid',non_billable:'Ikke-fakturerbar tid'}; const labels={pending:'Afventer',approved:'Godkendt',rejected:'Afvist',invoice:'Faktura',prepaid:'Klippekort',subscription:'Abonnement / fast pris',internal:'Intern tid',non_billable:'Ikke-fakturerbar tid',draft:'Kladde',operations:'Drift',development:'Udvikling',manual_review:'Manuel vurdering'};
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c])); const h=v=>Number(v||0).toLocaleString('da-DK',{maximumFractionDigits:2}); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c])); const h=v=>Number(v||0).toLocaleString('da-DK',{maximumFractionDigits:2});
const norm=v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[String(v||'invoice').toLowerCase()]||String(v||'invoice').toLowerCase(); const ids=()=>[...state.selected]; const norm=v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[String(v||'draft').toLowerCase()]||String(v||'draft').toLowerCase(); const ids=()=>[...state.selected];
function message(type,text){$('messageArea').innerHTML=`<div class="alert alert-${type} alert-dismissible fade show">${esc(text)}<button class="btn-close" data-bs-dismiss="alert"></button></div>`} function message(type,text){$('messageArea').innerHTML=`<div class="alert alert-${type} alert-dismissible fade show">${esc(text)}<button class="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} 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 allWorkflowSelected(){const rows=selectedRows();return rows.length>0&&rows.every(x=>x.workflow_active===true)} function syncWorkflowControls(){const active=allWorkflowSelected();$('workflowLineModeHost').classList.toggle('d-none',!active);for(const id of ['settlementMethod','prepaidCard','agreementId','setMethodBtn']){$(id).disabled=active;if(active&&id!=='setMethodBtn')$(id).value='';}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 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=>`<span class="badge text-bg-light border me-1" title="${esc(tag.name)}"><i class="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 `<span class="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 `<span class="badge ${cls}">${esc(labels[m]||m)}</span>`} function status(v){return `<span class="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 `<span class="badge ${cls}">${esc(labels[m]||m)}</span>`}
function isClosedCase(value){return ['lukket','løst','closed','resolved','afsluttet','done'].includes(String(value||'').trim().toLocaleLowerCase('da-DK'))} function caseStatus(v){if(!v)return '<span class="badge text-bg-light border">Uden sag</span>';return `<span class="badge ${isClosedCase(v)?'case-closed-badge':'case-open-badge'}">Sag: ${esc(v)}</span>`} function isClosedCase(value){return ['lukket','løst','closed','resolved','afsluttet','done'].includes(String(value||'').trim().toLocaleLowerCase('da-DK'))} function caseStatus(v){if(!v)return '<span class="badge text-bg-light border">Uden sag</span>';return `<span class="badge ${isClosedCase(v)?'case-closed-badge':'case-open-badge'}">Sag: ${esc(v)}</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;$('statClosedCases').textContent=state.items.filter(x=>isClosedCase(x.case_status)).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 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=>effectiveMethod(x)==='invoice'&&x.billable!==false).length;$('statClosedCases').textContent=state.items.filter(x=>isClosedCase(x.case_status)).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 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 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 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 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 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='<span class="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> <span class="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strong class="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`} 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='<span class="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> <span class="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strong class="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`}
function visibleItems(){const m=$('filterMethod').value;return state.items.filter(x=>!m||(m==='manual_review'?x.workflow_decision?.status==='manual_review':norm(x.workflow_decision?.method||x.billing_method)===m))} function render(){renderStats();syncWorkflowControls();if(!state.items.length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';return}const groups={};visibleItems().forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-outline-primary customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg alle ${items.length} tider</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${caseStatus(x.case_status)} ${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(norm(x.workflow_decision?.method||m))} ${x.workflow_decision?.status==='manual_review'?'<span class="badge text-bg-warning">Manuel vurdering</span>':''} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end">${state.workflowEnabled?`<a class="btn btn-sm btn-outline-primary me-1" href="/workflows/time/${id}" target="_blank" rel="noopener">Hvorfor?</a>`:''}<button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{const group=e.dataset.ids.split(',').map(Number),allSelected=group.every(id=>state.selected.has(id));group.forEach(id=>allSelected?state.selected.delete(id):state.selected.add(id));selectionChanged()}))} function methodFilteredItems(){const m=$('filterMethod').value;return state.items.filter(x=>!m||effectiveMethod(x)===m)} function visibleItems(){return methodFilteredItems().filter(x=>state.triage==='all'||(state.triage==='ready'?!reviewIssue(x):!!reviewIssue(x)))} function render(){renderStats();syncWorkflowControls();const filtered=methodFilteredItems();$('countAll').textContent=filtered.length;$('countReady').textContent=filtered.filter(x=>!reviewIssue(x)).length;$('countAction').textContent=filtered.filter(x=>!!reviewIssue(x)).length;document.querySelectorAll('.triage-tab').forEach(x=>{x.classList.toggle('btn-primary',x.dataset.triage===state.triage);x.classList.toggle('btn-outline-primary',x.dataset.triage!==state.triage)});if(!visibleItems().length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';return}const groups={};visibleItems().forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-outline-primary customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg alle ${items.length} tider</button></header>${items.map(x=>{const id=Number(x.id),m=effectiveMethod(x),issue=reviewIssue(x);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${caseStatus(x.case_status)} ${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end">${x.workflow_active?`<a class="btn btn-sm btn-outline-primary me-1" href="/workflows/time/${id}" target="_blank" rel="noopener">Hvorfor?</a>`:''}<button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4" title="${esc(x.description)}">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}${x.case_tags?.length?`<div class="small mt-2 ps-lg-4 queue-tags">${caseTags(x)}</div>`:''}${issue?`<div class="small text-warning mt-2 ps-lg-4">${esc(issue)}</div>`:workflowExplanation(x)?`<div class="small text-muted mt-2 ps-lg-4">${esc(workflowExplanation(x))}</div>`:''}</div>`}).join('')}</article>`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{const group=e.dataset.ids.split(',').map(Number),allSelected=group.every(id=>state.selected.has(id));group.forEach(id=>allSelected?state.selected.delete(id):state.selected.add(id));selectionChanged()}))}
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}`} 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=`<option value="">Alle firmaer</option>${(d.items||[]).map(x=>`<option value="${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 loadCustomers(){try{const d=await api('/api/v1/economy/time-queue/customers'),v=$('filterCustomer').value;$('filterCustomer').innerHTML=`<option value="">Alle firmaer</option>${(d.items||[]).map(x=>`<option value="${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(){if(allWorkflowSelected()){$('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=`<option value="">${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=`<option value="">Vælg kundens klippekort</option>${state.cards.map(x=>`<option value="${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='<option value="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}} 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=`<option value="">${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=`<option value="">Vælg kundens klippekort</option>${state.cards.map(x=>`<option value="${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='<option value="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}}
async function load(){if(state.loading)return;state.loading=true;$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter tidsregistreringer…</div>';try{const d=await api(url());state.items=d.items||[];state.workflowEnabled=!!d.workflow_enabled;state.selected=new Set(ids().filter(id=>state.items.some(x=>Number(x.id)===id)));state.preview=null;$('previewPanel').classList.add('d-none');render();await loadCards()}catch(e){$('queueContent').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`}finally{state.loading=false}} async function load(){if(state.loading)return;state.loading=true;invalidatePreview();$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter tidsregistreringer…</div>';try{const d=await api(url());state.items=d.items||[];state.workflowEnabled=!!d.workflow_enabled;state.selected=new Set(ids().filter(id=>state.items.some(x=>Number(x.id)===id)));state.preview=null;$('previewPanel').classList.add('d-none');render();await loadCards()}catch(e){$('queueContent').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`}finally{state.loading=false}}
async function saveMethod(){if(allWorkflowSelected())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)}} 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)}}
async function openCaseDetails(sagId){const row=state.items.find(x=>Number(x.sag_id)===sagId)||{},modal=bootstrap.Modal.getOrCreateInstance($('caseDetailModal'));$('caseDetailTitle').textContent=`#${sagId} · ${row.case_title||'Sag'}`;$('openCaseNewTab').href=`/sag/${sagId}/v3`;$('caseDetailBody').innerHTML='<div class="py-4 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter sagsdetaljer…</div>';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`<div class="d-flex flex-wrap gap-2 mb-3">${status(s.status)}<span class="badge text-bg-light border">${esc(s.type||'Sag')}</span><span class="badge text-bg-light border">${esc(s.prioritet||'Normal')}</span></div><div class="case-detail-meta mb-3"><div><small>Kunde</small><strong>${esc(row.customer_name||'Ikke tilknyttet')}</strong></div><div><small>Kontakt</small><strong>${esc(row.contact_name||'Ikke tilknyttet')}</strong></div><div><small>Ansvarlig</small><strong>${esc(s.ansvarlig_navn||'Ikke tildelt')}</strong></div><div><small>Senest opdateret</small><strong>${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}</strong></div></div><h3 class="h6">Beskrivelse</h3><div class="border rounded p-3 bg-light">${esc(description).replace(/\n/g,'<br>')}</div>`}catch(e){$('caseDetailBody').innerHTML=`<div class="alert alert-danger mb-0">${esc(e.message)}</div>`}} async function openCaseDetails(sagId){const row=state.items.find(x=>Number(x.sag_id)===sagId)||{},modal=bootstrap.Modal.getOrCreateInstance($('caseDetailModal'));$('caseDetailTitle').textContent=`#${sagId} · ${row.case_title||'Sag'}`;$('openCaseNewTab').href=`/sag/${sagId}/v5`;$('caseDetailBody').innerHTML='<div class="py-4 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter sagsdetaljer…</div>';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`<div class="d-flex flex-wrap gap-2 mb-3">${status(s.status)}<span class="badge text-bg-light border">${esc(s.type||'Sag')}</span><span class="badge text-bg-light border">${esc(s.prioritet||'Normal')}</span></div><div class="case-detail-meta mb-3"><div><small>Kunde</small><strong>${esc(row.customer_name||'Ikke tilknyttet')}</strong></div><div><small>Kontakt</small><strong>${esc(row.contact_name||'Ikke tilknyttet')}</strong></div><div><small>Ansvarlig</small><strong>${esc(s.ansvarlig_navn||'Ikke tildelt')}</strong></div><div><small>Senest opdateret</small><strong>${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}</strong></div></div><h3 class="h6">Beskrivelse</h3><div class="border rounded p-3 bg-light">${esc(description).replace(/\n/g,'<br>')}</div>`}catch(e){$('caseDetailBody').innerHTML=`<div class="alert alert-danger mb-0">${esc(e.message)}</div>`}}
function previewView(d){ function previewView(d){
state.preview=d; state.preview=d;
$('previewPanel').classList.remove('d-none'); $('previewPanel').classList.remove('d-none');
@ -66,10 +76,10 @@
$('settleBtn').disabled=!d.valid; $('settleBtn').disabled=!d.valid;
$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'}); $('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 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)return;$('settleBtn').disabled=true;$('settleBtn').innerHTML='<span class="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><a class="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='<i class="bi bi-check2-circle me-1"></i>Gennemfør afregning'}} 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='<span class="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><a class="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='<i class="bi bi-check2-circle me-1"></i>Gennemfør afregning'}}
function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()} function selectionChanged(){invalidatePreview();render();loadCards()}
$('workflowLineMode').onchange=()=>{state.preview=null;$('previewPanel').classList.add('d-none')};$('filterMethod').onchange=render;$('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterMethod').value='';$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterCaseState').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterCaseState'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load(); document.querySelectorAll('.triage-tab').forEach(x=>x.onclick=()=>{state.triage=x.dataset.triage;invalidatePreview();render()});$('selectReadyBtn').onclick=()=>{state.selected=new Set(visibleItems().filter(x=>!reviewIssue(x)).map(x=>Number(x.id)));selectionChanged()};['workflowLineMode','settlementMethod','agreementId'].forEach(id=>{$(id).oninput=invalidatePreview;$(id).onchange=invalidatePreview});$('filterMethod').onchange=()=>{invalidatePreview();render()};$('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{state.triage='all';invalidatePreview();$('filterMethod').value='';$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterCaseState').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterCaseState'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=()=>{invalidatePreview();renderPrepaidHelp()};$('filterQuery').oninput=invalidatePreview;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=invalidatePreview;$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load();
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,13 @@
# Manuel test af den smartere tidskø
Test lokalt på `/economy/time-queue` med en workflowkunde og en kunde uden workflows.
1. Kontrollér, at Fastpris og Klippekort vises ud fra den aktuelle beslutning. En kladde, manglende beslutning eller manuel vurdering må ikke vises som Faktura.
2. Skift mellem alle tider, klar til gennemgang og kræver handling. Kontrollér tællere og forklaringer. En forældet beslutning skal kræve handling.
3. Filtrér på firma, søgning og metode. Vælg de synlige tider, der er klar til gennemgang: skjulte tider må ikke blive valgt.
4. Vælg både workflowtid og almindelig tid. Køen skal bede om separate udvalg frem for at overstyre workflowvalget.
5. Forhåndsvis et udvalg. Ændr derefter metode, aftale, kort eller udvalgte tider: gennemførsel skal kræve en ny forhåndsvisning.
6. Åbn sagsdetaljer og saglink. Kontrollér sagens tags, beskrivelse, medarbejder, dato, timer og afregningsvalg.
7. Brug kun særskilte testregistreringer ved faktisk afregning. Kontrollér ordrekladde/kortsaldo før og efter; en forhåndsvisning alene må ikke ændre dem.
Klar til gennemgang er en indledende sortering. Forhåndsvisningen kontrollerer fortsat aftaler, saldo, beløb og aktuelle beslutninger inden gennemførsel.

View File

@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
import asyncio import asyncio
import pytest
from app.core.config import settings
from app.economy.backend.router import ( from app.economy.backend.router import (
SettlementRequest, SettlementRequest,
@ -12,6 +14,11 @@ from app.economy.backend.router import (
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture(autouse=True)
def legacy_queue_mode(monkeypatch):
monkeypatch.setattr(settings, "WORKFLOWS_ENABLED", False)
def test_time_queue_normalises_legacy_settlement_names(): def test_time_queue_normalises_legacy_settlement_names():
assert _normalise_billing_method("prepaid_card") == "prepaid" assert _normalise_billing_method("prepaid_card") == "prepaid"
assert _normalise_billing_method("fixed_price") == "subscription" assert _normalise_billing_method("fixed_price") == "subscription"

View File

@ -0,0 +1,38 @@
from app.economy.backend import router
from app.workflows import integration
def test_queue_checks_activation_once_per_customer_without_changing_methods(monkeypatch):
calls=[]
def active(customer):
calls.append(customer)
return customer==17
monkeypatch.setattr(integration,'active_for_customer',active)
monkeypatch.setattr(router,'execute_query',lambda *args:[{'time_id':1,'revision':2,'entry_revision':2,'method':'subscription','status':'decided','details':{'billing':{'agreement_id':2}}}])
rows=[{'id':1,'customer_id':17,'billing_method':'draft'}, {'id':2,'customer_id':17,'billing_method':'draft'}, {'id':3,'customer_id':18,'billing_method':'invoice'}]
router._attach_workflow_decisions(rows)
assert calls==[17,18]
assert rows[0]['billing_method']=='subscription'
assert rows[0]['fixed_price_agreement_id']==2
assert rows[1]['workflow_active'] is True
assert rows[2]['billing_method']=='invoice'
assert rows[2]['workflow_active'] is False
def test_case_tags_are_batched_and_legacy_used_only_for_cases_without_generic_tags(monkeypatch):
calls=[]
def query(sql,params):
calls.append((sql,params))
if 'FROM entity_tags' in sql:
return [{'sag_id':196,'id':7,'name':'ABA','color':'#009999','icon':'bi-tag'}]
return [{'sag_id':197,'id':9,'name':'Legacy'}]
monkeypatch.setattr(router,'execute_query',query)
rows=[{'id':1,'sag_id':196},{'id':2,'sag_id':196},{'id':3,'sag_id':197},{'id':4,'sag_id':None}]
router._attach_case_tags(rows)
assert len(calls)==2
assert calls[0][1]==([196,197],)
assert calls[1][1]==([197],)
assert rows[0]['case_tags']==rows[1]['case_tags']
assert rows[0]['case_tags'][0]['name']=='ABA'
assert rows[2]['case_tags'][0]['name']=='Legacy'
assert rows[3]['case_tags']==[]

View File

@ -0,0 +1,58 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const fs=require('node:fs');
const html=fs.readFileSync('app/economy/frontend/time_queue.html','utf8');
function fixture(rows=[]){
const controls={};for(const id of ['settlementMethod','prepaidCard','agreementId','workflowLineMode','workflowLineModeHost','setMethodBtn','previewBtn','stickyPreviewBtn','selectionText','previewPanel','settleBtn','filterMethod','filterCustomer','filterStatus','filterCaseState','filterBillable','filterQuery'])controls[id]={value:'',disabled:false,classList:{add(){},toggle(){}}};
controls.workflowLineMode.value='separate';
const state={items:rows,selected:new Set(rows.map(x=>x.id)),triage:'all',preview:{valid:true},previewPayload:{ids:[1]},previewFingerprint:'old'};
const ctx={state,$:id=>controls[id],norm:v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[v]||v||'draft',ids:()=>[...state.selected],selectedRows:()=>rows,Number,JSON};
vm.createContext(ctx);vm.runInContext(html.slice(html.indexOf(' function effectiveMethod('),html.indexOf(' function status(')),ctx);
return {ctx,controls,state};
}
test('workflow stale, absent or manual review never inherits invoice fallback',()=>{
const {ctx}=fixture();
assert.equal(ctx.effectiveMethod({workflow_active:true,billing_method:'invoice'}),'draft');
assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_stale:true,workflow_decision:{method:'invoice',status:'decided'}}),'draft');
assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_decision:{method:'invoice',status:'manual_review'}}),'manual_review');
assert.match(ctx.reviewIssue({workflow_active:true,workflow_stale:true}),/Genberegn/);
});
test('readiness flags missing card/agreement and accepts concrete workflow selection',()=>{
const {ctx}=fixture();const base={original_hours:1,customer_id:9,status:'pending'};
assert.match(ctx.reviewIssue({...base,billing_method:'prepaid'}),/klippekort/);
assert.match(ctx.reviewIssue({...base,billing_method:'fixed_price'}),/aftale/);
assert.equal(ctx.reviewIssue({...base,workflow_active:true,workflow_decision:{method:'subscription',status:'decided'},fixed_price_agreement_id:2}),'');
assert.match(ctx.reviewIssue({...base,billing_method:'invoice',billable:false}),/ikke-fakturerbar/);
});
test('mixed selection disables bulk overrides and preview instead of leaking configuration',()=>{
const {ctx,controls}=fixture([{id:1,workflow_active:true},{id:2,workflow_active:false}]);
controls.settlementMethod.value='invoice';controls.prepaidCard.value='7';ctx.syncWorkflowControls();
assert.equal(controls.setMethodBtn.disabled,true);assert.equal(controls.previewBtn.disabled,true);assert.equal(controls.stickyPreviewBtn.disabled,true);assert.equal(controls.settlementMethod.value,'');assert.equal(controls.prepaidCard.value,'');assert.match(controls.selectionText.textContent,/hver for sig/);
});
test('preview fingerprint tracks billing inputs, filters, selection and line grouping',()=>{
const {ctx,controls,state}=fixture([{id:1,workflow_active:true}]);const initial=ctx.fingerprint();
controls.workflowLineMode.value='combined';assert.notEqual(ctx.fingerprint(),initial);
const grouped=ctx.fingerprint();controls.filterQuery.value='ABA';assert.notEqual(ctx.fingerprint(),grouped);
const filtered=ctx.fingerprint();state.selected.add(2);assert.notEqual(ctx.fingerprint(),filtered);
const selected=ctx.fingerprint();controls.agreementId.value='42';assert.notEqual(ctx.fingerprint(),selected);
ctx.invalidatePreview();assert.equal(state.preview,null);assert.equal(state.previewPayload,null);assert.equal(controls.settleBtn.disabled,true);
});
test('settlement sends frozen preview request, never a mutated billing configuration',async()=>{
const {ctx,controls,state}=fixture([{id:1}]);let sent=null;ctx.message=()=>{};ctx.api=async(url,options)=>{sent=JSON.parse(options.body);return {}};ctx.loadCustomers=async()=>{};ctx.load=async()=>{};
state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint();
vm.runInContext(html.slice(html.indexOf(' async function settle(){'),html.indexOf(' function selectionChanged()')),ctx);
await ctx.settle();assert.equal(sent.billing_method,'invoice');assert.deepEqual(sent.ids,[1]);
state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint();controls.agreementId.value='99';sent=null;
await ctx.settle();assert.equal(sent,null);assert.equal(state.preview,null);
});
test('triage and select-ready respect the visible billing filter',()=>{
const rows=[{id:1,billing_method:'invoice',original_hours:1,customer_id:9},{id:2,billing_method:'draft',original_hours:1},{id:3,billing_method:'prepaid',original_hours:1,prepaid_card_id:8}];
const {ctx,controls,state}=fixture(rows);vm.runInContext(html.slice(html.indexOf(' function methodFilteredItems('),html.indexOf(' function render(){')),ctx);
state.triage='action';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[2]);state.triage='ready';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[1,3]);
controls.filterMethod.value='prepaid';ctx.selectionChanged=()=>{};vm.runInContext(html.slice(html.indexOf("$('selectReadyBtn').onclick="),html.indexOf(";['workflowLineMode'",html.indexOf("$('selectReadyBtn').onclick="))),Object.assign(ctx,{ $:id=>id==='selectReadyBtn'?(controls.selectReadyBtn??={}):controls[id]}));controls.selectReadyBtn.onclick();assert.deepEqual([...state.selected],[3]);
});
test('case tags escape names without inserting customer-controlled styles',()=>{
const {ctx}=fixture();ctx.esc=v=>String(v).replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
const out=ctx.caseTags({case_tags:[{name:'ABA <script>',color:'red;position:fixed'}]});assert.match(out,/ABA &lt;script&gt;/);assert.ok(!out.includes('position:fixed'));assert.match(out,/bi-tag/);
});

View File

@ -38,13 +38,13 @@ loadBillingOptions().then(()=>{
@pytest.mark.parametrize('active', [True, False]) @pytest.mark.parametrize('active', [True, False])
def test_queue_active_selection_uses_decisions_and_line_mode(active): def test_queue_active_selection_uses_decisions_and_line_mode(active):
source = Path('app/economy/frontend/time_queue.html').read_text() source = Path('app/economy/frontend/time_queue.html').read_text()
functions = source.split('function allWorkflowSelected()', 1)[1].split(' function status(', 1)[0] functions = source.split('function selectionMode(rows)', 1)[1].split(' function status(', 1)[0]
script = r''' script = r'''
const assert=require('node:assert/strict'); const assert=require('node:assert/strict');
const active=ACTIVE; const active=ACTIVE;
const controls={settlementMethod:{value:'prepaid'},prepaidCard:{value:'8'},agreementId:{value:''},setMethodBtn:{},workflowLineMode:{value:'combined'},workflowLineModeHost:{classList:{toggle(){}}}}; const controls={settlementMethod:{value:'prepaid'},prepaidCard:{value:'8'},agreementId:{value:''},setMethodBtn:{},workflowLineMode:{value:'combined'},workflowLineModeHost:{classList:{toggle(){}}},previewBtn:{},stickyPreviewBtn:{},selectionText:{}};
const $=id=>controls[id],ids=()=>[1],selectedRows=()=>[{id:1,workflow_active:active}]; const $=id=>controls[id],ids=()=>[1],selectedRows=()=>[{id:1,workflow_active:active}];
function allWorkflowSelected() BODY function selectionMode(rows) BODY
const data=payload(); const data=payload();
assert.deepEqual(data,active?{ids:[1],line_mode:'combined'}:{ids:[1],billing_method:'prepaid',prepaid_card_id:8}); assert.deepEqual(data,active?{ids:[1],line_mode:'combined'}:{ids:[1],billing_method:'prepaid',prepaid_card_id:8});
syncWorkflowControls();assert.equal(controls.settlementMethod.disabled,active);assert.equal(controls.setMethodBtn.disabled,active); syncWorkflowControls();assert.equal(controls.settlementMethod.disabled,active);assert.equal(controls.setMethodBtn.disabled,active);