Make economy time queue actionable and show case tags
This commit is contained in:
parent
8012423844
commit
cdb9d8c178
@ -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']
|
||||||
|
|||||||
@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const h=v=>Number(v||0).toLocaleString('da-DK',{maximumFractionDigits:2});
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||||
|
|||||||
13
docs/tidsko-manuel-testplan.md
Normal file
13
docs/tidsko-manuel-testplan.md
Normal 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.
|
||||||
@ -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"
|
||||||
|
|||||||
38
tests/test_economy_workflow_activation_cache.py
Normal file
38
tests/test_economy_workflow_activation_cache.py
Normal 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']==[]
|
||||||
58
tests/test_smart_time_queue.js
Normal file
58
tests/test_smart_time_queue.js
Normal 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('<','<').replaceAll('>','>').replaceAll('"','"');
|
||||||
|
const out=ctx.caseTags({case_tags:[{name:'ABA <script>',color:'red;position:fixed'}]});assert.match(out,/ABA <script>/);assert.ok(!out.includes('position:fixed'));assert.match(out,/bi-tag/);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user