Align workflow pages with Hub design and permission-aware menu

This commit is contained in:
Christian 2026-10-08 20:34:18 +02:00
parent b398278b13
commit 5eece0d704
19 changed files with 657 additions and 49 deletions

View File

@ -30,6 +30,8 @@
--bottom-bar-zindex: 1030;
}
[data-menu-permission][data-access-denied] { display: none !important; }
/* Keep the content viewport stable when a tall dropdown opens/closes.
Without a reserved scrollbar gutter, wide case hero sections visibly
jump a few pixels on smaller monitors. */
@ -1555,6 +1557,15 @@
<li data-menu-key="menu-okonomi-invoice-error-finder"><a class="dropdown-item py-2" href="/invoice-error-finder"><i class="bi bi-search me-2"></i>Faktura-fejl-finder</a></li>
</ul>
</li>
<li class="nav-item dropdown" data-menu-key="menu-workflows" data-menu-permission="workflows.view|system.admin" data-access-denied>
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-diagram-3 me-2"></i>Automatisering</a>
<ul class="dropdown-menu mt-2">
<li data-menu-key="menu-workflows-builder" data-menu-permission="workflows.view" data-access-denied><a class="dropdown-item py-2 {{ 'active' if request and request.url.path == '/workflows' else '' }}" href="/workflows"><i class="bi bi-diagram-3 me-2"></i>Workflows</a></li>
<li data-menu-key="menu-workflows-resources" data-menu-permission="workflows.view,workflows.manage" data-access-denied><a class="dropdown-item py-2 {{ 'active' if request and request.url.path == '/workflows/resources' else '' }}" href="/workflows/resources"><i class="bi bi-credit-card-2-front me-2"></i>Aftaledækning</a></li>
<li data-menu-key="menu-workflows-operations" data-menu-permission="workflows.view" data-access-denied><a class="dropdown-item py-2 {{ 'active' if request and request.url.path == '/workflows/operations' else '' }}" href="/workflows/operations"><i class="bi bi-activity me-2"></i>Workflowdrift</a></li>
<li data-menu-key="menu-workflows-classification" data-menu-permission="system.admin" data-access-denied><a class="dropdown-item py-2 {{ 'active' if request and request.url.path == '/tags/classification' else '' }}" href="/tags/classification"><i class="bi bi-tags me-2"></i>Tagklassifikation</a></li>
</ul>
</li>
</ul>
<div class="navbar-actions d-flex align-items-center gap-3">
<div class="dropdown" data-menu-key="menu-datamigration">
@ -2007,6 +2018,7 @@ if (bmcOriginalFetch) {
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.3"></script>
<script src="/static/js/menu-access.js?v=20261008-1"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.6"></script>
@ -2059,6 +2071,7 @@ if (bmcOriginalFetch) {
function isVisibleActionItem(li) {
if (!li) return false;
if (li.hasAttribute('data-access-denied')) return false;
const anchor = li.querySelector('a.dropdown-item');
if (!anchor) return false;
if (li.classList.contains('bmc-menu-hidden') || anchor.classList.contains('bmc-menu-hidden')) return false;
@ -3136,6 +3149,11 @@ if (bmcOriginalFetch) {
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
{ key: 'menu-okonomi-time-queue', label: 'Økonomi: Time Queue' },
{ key: 'menu-workflows', label: 'Automatisering' },
{ key: 'menu-workflows-builder', label: 'Workflows' },
{ key: 'menu-workflows-resources', label: 'Workflows: Aftaledækning' },
{ key: 'menu-workflows-operations', label: 'Workflows: Drift og eksport' },
{ key: 'menu-workflows-classification', label: 'Tags: Klassifikation' },
{ key: 'menu-okonomi-also-cloud', label: 'Økonomi: ALSO Cloud Marketplace' },
{ key: 'menu-okonomi-supplier-invoices', label: 'Økonomi: Leverandør fakturaer' },
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
@ -3470,6 +3488,8 @@ if (bmcOriginalFetch) {
if (!res.ok) return;
const user = await res.json();
window.BmcMenuAccess.apply(user, document);
cleanupNavbarDropdowns(new Set(window.__bmcMenuHiddenKeys || []));
const displayName = String(user.full_name || user.username || user.email || 'Bruger').trim() || 'Bruger';
const avatarEl = document.getElementById('currentUserAvatar');
const nameEl = document.getElementById('currentUserDisplayName');

View File

@ -1,12 +1,14 @@
"""
Tag system frontend views
"""
from fastapi import APIRouter, Request
from fastapi import APIRouter, Request, Depends
from app.core.auth_dependencies import require_permission
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
router = APIRouter()
templates = Jinja2Templates(directory="app")
classification_access = require_permission('system.admin')
@router.get("/tags", response_class=HTMLResponse)
async def tags_admin_page(request: Request):
@ -14,5 +16,6 @@ async def tags_admin_page(request: Request):
return templates.TemplateResponse("tags/frontend/tags_admin.html", {"request": request})
@router.get('/tags/classification', response_class=HTMLResponse)
async def classification_page(request: Request):
return templates.TemplateResponse('tags/frontend/classification.html', {'request':request})
async def classification_page(request: Request, user=Depends(classification_access)):
from app.workflows.ui_access import page_permissions
return templates.TemplateResponse('tags/frontend/classification.html', {'request':request, 'workflow_permissions': page_permissions(user)})

View File

@ -1,25 +1,38 @@
{% extends 'shared/frontend/base.html' %}
{% block title %}Tagklassifikation – BMC Hub{% endblock %}
{% block extra_css %}{% include 'workflows/frontend/_style.html' %}
<style>
.workflow-page .classification-tree{max-height:60vh;overflow:auto}.workflow-page .rule{display:grid!important;grid-template-columns:150px minmax(130px,1fr) minmax(130px,1fr) 85px 32px;align-items:center}.workflow-page .rule .btn{padding:.3rem}.workflow-page #history{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.78rem}@media(max-width:767px){.workflow-page .rule{grid-template-columns:1fr 1fr}.workflow-page .rule .words,.workflow-page .rule .negatives{grid-column:1 / -1}.workflow-page .classification-tree{max-height:280px}}
</style>{% endblock %}
{% block content %}
<div class="container-fluid py-3"><h2>Taghierarki og klassifikation</h2><a href="/tags">Tilbage til tags</a>
<div id="notice" role="status" class="my-2"></div>
<div class="row mt-3"><section class="col-md-5"><label for="search">Søg tag eller overtag</label><input id="search" class="form-control" placeholder="Søg tags"><div id="tree" class="list-group mt-2" style="max-height:65vh;overflow:auto"></div></section>
<section class="col-md-7"><h3 id="selected">Vælg et tag</h3><form id="edit" hidden><label for="parent">Overtag</label><select id="parent" class="form-select"></select><label for="visibility">Synlighed</label><select id="visibility" class="form-select"><option value="public">Offentlig</option><option value="internal">Intern</option></select><label class="my-2"><input id="billing" type="checkbox"> Kan påvirke afregning</label><button class="btn btn-primary d-block">Gem hierarki</button></form>
<div id="keywordsPanel" hidden class="mt-3"><h4>Nøgleordsregler</h4><p>En regel pr. række. Alternative ord og negative ord adskilles med komma.</p><div id="keywords"></div><button id="addRule" class="btn btn-outline-secondary">Tilføj regel</button><button id="saveRules" class="btn btn-primary">Gem regler</button></div></section></div>
<hr><h3>Forslag og historik</h3><div class="d-flex gap-2"><select id="entityType" class="form-select w-auto"><option value="time_entry">Tidsregistrering</option><option value="case">Sag</option><option value="email">Mail</option></select><input id="entityId" type="number" min="1" class="form-control w-auto" placeholder="ID"><button id="loadProposals" class="btn btn-secondary">Vis</button></div><div id="proposals" class="mt-2"></div><details><summary>Historik</summary><div id="history"></div></details></div>
<div class="workflow-page">
<header class="workflow-heading"><div><div class="workflow-eyebrow">Automatisering</div><h1><i class="bi bi-tags me-2"></i>Tagklassifikation</h1><p>Organisér tags, vedligehold nøgleord og gennemgå automatiske forslag.</p></div><a class="btn btn-sm btn-outline-secondary" href="/tags"><i class="bi bi-arrow-left me-1"></i>Tagadministration</a></header>
{% include 'workflows/frontend/_nav.html' %}
<div id="notice" role="status" aria-live="polite" class="my-2"></div>
<div class="row g-3">
<aside class="col-lg-4"><section class="card p-3"><label for="search" class="mb-1">Søg tag eller overtag</label><input id="search" class="form-control mb-3" placeholder="Fx Excel eller Microsoft"><div id="tree" class="list-group classification-tree" aria-label="Taghierarki"><div class="workflow-empty">Henter tags…</div></div></section></aside>
<section class="col-lg-8"><div class="card p-3"><h5 id="selected">Vælg et tag i oversigten</h5><p class="small text-muted">Overtagets regler kan omfatte alle undertags. Kun det konkrete tag behøver at være tildelt.</p>
<form id="edit" hidden><div class="row g-2"><div class="col-md-7"><label for="parent">Overtag</label><select id="parent" class="form-select"></select></div><div class="col-md-5"><label for="visibility">Synlighed</label><select id="visibility" class="form-select"><option value="public">Offentlig</option><option value="internal">Intern</option></select></div></div><label class="my-3"><input id="billing" type="checkbox" class="form-check-input me-1"> Kan påvirke afregning</label><div><button type="submit" class="btn btn-primary">Gem hierarki</button></div></form>
<div id="keywordsPanel" hidden class="mt-3 border-top pt-3"><h5>Nøgleordsregler</h5><p class="small text-muted">En regel pr. række. Adskil alternative og negative ord med komma. Laveste prioritet behandles først.</p><div id="keywords"></div><div class="workflow-actions mt-2"><button type="button" id="addRule" class="btn btn-outline-secondary"><i class="bi bi-plus me-1"></i>Tilføj regel</button><button type="button" id="saveRules" class="btn btn-primary">Gem regler</button></div></div>
</div></section>
</div>
<section class="card p-3 mt-3"><h5>Forslag og historik</h5><p class="small text-muted">Vælg sag, tid eller mail og indtast dens ID. Godkend eller afvis forslag med en begrundelse.</p><div class="row g-2 align-items-end"><div class="col-sm-4 col-lg-3"><label for="entityType">Område</label><select id="entityType" class="form-select"><option value="time_entry">Tidsregistrering</option><option value="case">Sag</option><option value="email">Mail</option></select></div><div class="col-sm-4 col-lg-3"><label for="entityId">ID</label><input id="entityId" type="number" min="1" class="form-control" placeholder="Fx 4521"></div><div class="col-sm-4"><button type="button" id="loadProposals" class="btn btn-outline-primary">Vis forslag</button></div></div><div id="proposals" class="mt-3"><div class="workflow-empty">Vælg et område og et ID for at se forslag.</div></div><details class="mt-3"><summary>Taghistorik</summary><div id="history" class="mt-2">Vælg data ovenfor.</div></details></section>
</div>
{% endblock %}
{% block extra_js %}
<script>
const root='/api/v1/tags/classification';let tags=[],selected=null;
const el=id=>document.getElementById(id);const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(path,method='GET',body){const r=await fetch(root+path,{method,headers:{'Content-Type':'application/json'},...(body!==undefined?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.detail||'Kunne ikke gemme');return data}
function report(error){el('notice').textContent=error.message||error;el('notice').className='alert alert-warning'}
function report(error){el('notice').textContent=error.message||error;el('notice').className='alert alert-danger';}function saved(text){el('notice').textContent=text;el('notice').className='alert alert-success';}
function label(t){let path=[t.name],seen=new Set([t.id]),p=t.parent_id;while(p){let parent=tags.find(x=>x.id===p);if(!parent||seen.has(p))break;seen.add(p);path.unshift(parent.name);p=parent.parent_id}return path.join(' / ')}
function renderTree(){const q=el('search').value.toLowerCase();el('tree').innerHTML=tags.filter(t=>label(t).toLowerCase().includes(q)).map(t=>`<button class="list-group-item list-group-item-action" data-id="${t.id}">${esc(label(t))}</button>`).join('');el('tree').querySelectorAll('button').forEach(b=>b.onclick=()=>selectTag(Number(b.dataset.id)).catch(report))}
function renderTree(){const q=el('search').value.toLowerCase();el('tree').innerHTML=tags.filter(t=>label(t).toLowerCase().includes(q)).map(t=>`<button class="list-group-item list-group-item-action" data-id="${t.id}">${esc(label(t))}</button>`).join('')||'<div class="workflow-empty">Ingen tags matcher søgningen.</div>';el('tree').querySelectorAll('button').forEach(b=>b.onclick=()=>selectTag(Number(b.dataset.id)).catch(report))}
async function selectTag(id){selected=tags.find(t=>t.id===id);el('selected').textContent=label(selected);el('edit').hidden=false;el('keywordsPanel').hidden=false;el('parent').innerHTML='<option value="">Intet overtag</option>'+tags.filter(t=>t.id!==id).map(t=>`<option value="${t.id}">${esc(label(t))}</option>`).join('');el('parent').value=selected.parent_id||'';el('visibility').value=selected.visibility;el('billing').checked=selected.billing_relevant;el('keywords').innerHTML='';(await api('/keywords/'+id)).forEach(addRule)}
function addRule(rule={}){const row=document.createElement('div');row.className='rule d-flex gap-1 mb-2';row.innerHTML=`<select class="form-select mode"><option value="contains">Indeholder</option><option value="exact">Eksakt tekst</option><option value="whole_word">Hele ord</option></select><input class="form-control words" aria-label="Alternative ord" placeholder="Ord" value="${esc((rule.alternatives||[]).join(', '))}"><input class="form-control negatives" aria-label="Negative ord" placeholder="Negative ord" value="${esc((rule.negative_words||[]).join(', '))}"><input class="form-control priority" aria-label="Prioritet" type="number" value="${rule.priority??100}" style="width:85px"><button class="btn btn-outline-danger" aria-label="Fjern regel">×</button>`;row.querySelector('.mode').value=rule.mode||'contains';row.querySelector('button').onclick=()=>row.remove();el('keywords').append(row)}
function addRule(rule={}){const row=document.createElement('div');row.className='rule d-flex gap-1 mb-2';row.innerHTML=`<select class="form-select mode"><option value="contains">Indeholder</option><option value="exact">Eksakt tekst</option><option value="whole_word">Hele ord</option></select><input class="form-control words" aria-label="Alternative ord" placeholder="Alternative ord" value="${esc((rule.alternatives||[]).join(', '))}"><input class="form-control negatives" aria-label="Negative ord" placeholder="Negative ord" value="${esc((rule.negative_words||[]).join(', '))}"><input class="form-control priority" aria-label="Prioritet" type="number" value="${rule.priority??100}" style="width:85px"><button class="btn btn-outline-danger" aria-label="Fjern regel">×</button>`;row.querySelector('.mode').value=rule.mode||'contains';row.querySelector('button').onclick=()=>row.remove();el('keywords').append(row)}
el('search').oninput=renderTree;el('addRule').onclick=()=>addRule();
el('edit').onsubmit=async e=>{e.preventDefault();try{await api('/hierarchy/'+selected.id,'PUT',{parent_id:el('parent').value?Number(el('parent').value):null,visibility:el('visibility').value,billing_relevant:el('billing').checked});tags=await api('/hierarchy');renderTree();el('notice').textContent='Hierarki gemt'}catch(err){report(err)}};
el('saveRules').onclick=async()=>{try{const split=v=>v.split(',').map(x=>x.trim()).filter(Boolean);await api('/keywords/'+selected.id,'PUT',[...el('keywords').children].map(r=>({mode:r.querySelector('.mode').value,alternatives:split(r.querySelector('.words').value),negative_words:split(r.querySelector('.negatives').value),priority:Number(r.querySelector('.priority').value)})));el('notice').textContent='Regler gemt'}catch(err){report(err)}};
async function loadProposals(){const suffix='/'+el('entityType').value+'/'+Number(el('entityId').value);const rows=await api('/proposals'+suffix);el('proposals').innerHTML=rows.map(p=>`<div class="card p-2 mb-2"><strong>${esc(tags.find(t=>t.id===p.tag_id)?.name||p.tag_id)} · ${esc(p.status)} · ${esc(p.confidence)}%</strong><span>${esc(p.explanation)}</span>${['pending','manual_review'].includes(p.status)?`<input class="form-control reason" placeholder="Begrundelse" aria-label="Begrundelse"><div><button class="btn btn-success" data-id="${p.id}" data-approve="true">Godkend</button> <button class="btn btn-outline-danger" data-id="${p.id}" data-approve="false">Afvis</button></div>`:''}</div>`).join('');el('proposals').querySelectorAll('button').forEach(b=>b.onclick=async()=>{try{const reason=b.closest('.card').querySelector('.reason').value.trim();if(!reason)throw Error('Angiv en begrundelse');await api('/proposals/'+b.dataset.id+'/review','POST',{approve:b.dataset.approve==='true',reason});await loadProposals()}catch(err){report(err)}});el('history').textContent=JSON.stringify(await api('/history'+suffix),null,2)}
el('loadProposals').onclick=()=>loadProposals().catch(report);api('/hierarchy').then(data=>{tags=data;renderTree()}).catch(report);
el('edit').onsubmit=async e=>{e.preventDefault();try{await api('/hierarchy/'+selected.id,'PUT',{parent_id:el('parent').value?Number(el('parent').value):null,visibility:el('visibility').value,billing_relevant:el('billing').checked});tags=await api('/hierarchy');renderTree();await selectTag(selected.id);saved('Hierarki gemt.')}catch(err){report(err)}};
el('saveRules').onclick=async()=>{try{const split=v=>v.split(',').map(x=>x.trim()).filter(Boolean);await api('/keywords/'+selected.id,'PUT',[...el('keywords').children].map(r=>({mode:r.querySelector('.mode').value,alternatives:split(r.querySelector('.words').value),negative_words:split(r.querySelector('.negatives').value),priority:Number(r.querySelector('.priority').value)})));saved('Nøgleordsregler gemt.')}catch(err){report(err)}};
async function loadProposals(){if(!Number.isInteger(Number(el('entityId').value))||Number(el('entityId').value)<=0)throw Error('Angiv et gyldigt ID');const suffix='/'+el('entityType').value+'/'+Number(el('entityId').value);const rows=await api('/proposals'+suffix);el('proposals').innerHTML=rows.map(p=>`<div class="card p-2 mb-2"><strong>${esc(tags.find(t=>t.id===p.tag_id)?.name||p.tag_id)} · ${esc(p.status)} · ${esc(p.confidence)}%</strong><span>${esc(p.explanation)}</span>${['pending','manual_review'].includes(p.status)?`<input class="form-control reason" placeholder="Begrundelse" aria-label="Begrundelse"><div><button class="btn btn-success" data-id="${p.id}" data-approve="true">Godkend</button> <button class="btn btn-outline-danger" data-id="${p.id}" data-approve="false">Afvis</button></div>`:''}</div>`).join('')||'<div class="workflow-empty">Ingen klassifikationsforslag for de valgte data.</div>';el('proposals').querySelectorAll('button').forEach(b=>b.onclick=async()=>{try{const reason=b.closest('.card').querySelector('.reason').value.trim();if(!reason)throw Error('Angiv en begrundelse');await api('/proposals/'+b.dataset.id+'/review','POST',{approve:b.dataset.approve==='true',reason});await loadProposals()}catch(err){report(err)}});el('history').textContent=JSON.stringify(await api('/history'+suffix),null,2)}
el('loadProposals').onclick=async()=>{const button=el('loadProposals');button.disabled=true;try{await loadProposals()}catch(e){report(e)}finally{button.disabled=false}};el('entityId').onkeydown=e=>{if(e.key==='Enter')el('loadProposals').click()};api('/hierarchy').then(data=>{tags=data;renderTree()}).catch(report);
</script>
{% endblock %}

View File

@ -125,7 +125,7 @@
{% endblock %}
{% block content %}
<div class="container-fluid pt-2"><a href="/tags/classification" class="btn btn-outline-secondary btn-sm">Hierarki og klassifikation</a></div>
<div class="container-fluid pt-2"><a href="/tags/classification" class="btn btn-outline-secondary btn-sm" data-menu-permission="system.admin" data-access-denied>Hierarki og klassifikation</a></div>
<div class="container-fluid py-4">
<div class="row mb-4">
<div class="col">

View File

@ -131,4 +131,5 @@ def recalculate(time_id: int, payload: RecalculateRequest, user=Depends(edit_acc
@views.get('/workflows/time/{time_id}', response_class=HTMLResponse)
def decision_page(time_id: int, request: Request, user=Depends(view_access)):
return Jinja2Templates(directory='app').TemplateResponse('workflows/frontend/decision.html', {'request': request, 'time_id': time_id})
from .ui_access import page_permissions
return Jinja2Templates(directory='app').TemplateResponse('workflows/frontend/decision.html', {'request': request, 'time_id': time_id, 'workflow_permissions': page_permissions(user)})

View File

@ -0,0 +1,6 @@
{% set wp = workflow_permissions|default({}) %}
<nav class="workflow-nav" aria-label="Workflowområder">
{% if wp.get('view') %}<a href="/workflows" {% if request.url.path == '/workflows' %}aria-current="page"{% endif %}><i class="bi bi-diagram-3 me-1"></i>Workflows</a><a href="/workflows/operations" {% if request.url.path == '/workflows/operations' %}aria-current="page"{% endif %}><i class="bi bi-activity me-1"></i>Drift og eksport</a>{% endif %}
{% if wp.get('view') and wp.get('manage') %}<a href="/workflows/resources" {% if request.url.path == '/workflows/resources' %}aria-current="page"{% endif %}>Aftaler og klippekort</a>{% endif %}{% if wp.get('system_admin') %}<a href="/tags/classification">Tags og klassifikation</a>{% endif %}
{% if wp.get('settle') %}<a href="/economy/time-queue">Tidskø til afregning</a>{% endif %}
</nav>

View File

@ -0,0 +1,3 @@
<style>
.workflow-page{max-width:1700px;margin-inline:auto;padding:0}.workflow-page .workflow-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:1rem}.workflow-page .workflow-heading h1{font-size:1.6rem;margin:0 0 .3rem}.workflow-page .workflow-heading p{margin:0;color:var(--text-secondary,#6c757d);max-width:850px}.workflow-page .workflow-eyebrow{color:var(--accent,#0f4c75);font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;margin-bottom:.25rem}.workflow-page .workflow-nav{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}.workflow-page .workflow-nav a{font-size:.8rem;padding:.3rem .6rem;border:1px solid var(--frame-border-strong,#d1d5db);border-radius:var(--border-radius,.5rem);color:var(--text-primary,inherit);text-decoration:none;background:var(--bg-card,transparent)}.workflow-page .workflow-nav a[aria-current=page]{border-color:var(--accent);color:var(--accent);font-weight:600}.workflow-page .card:hover{transform:none}.workflow-page label{font-size:.8rem;font-weight:500}.workflow-page .form-control,.workflow-page .form-select{font-size:.83rem;min-height:32px}.workflow-page .btn{font-size:.82rem}.workflow-page .workflow-actions{display:flex;gap:.4rem;flex-wrap:wrap;align-items:center}.workflow-page .workflow-sidebar{position:sticky;top:1rem;max-height:calc(100vh - 8rem);overflow:auto}.workflow-page .list-group-item{font-size:.84rem;padding:.55rem .7rem}.workflow-page h5{font-size:1rem}.workflow-page h6{font-size:.86rem}.workflow-page pre{font-size:.75rem;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;max-height:350px;overflow:auto;background:var(--bg-card,transparent)!important;color:var(--text-primary,inherit);border:1px solid var(--frame-border-strong,#d1d5db);border-radius:.4rem}.workflow-page details{border:1px solid var(--frame-border-strong,#d1d5db);border-radius:.4rem;padding:.5rem .7rem;margin:.45rem 0}.workflow-page details summary{font-size:.8rem;cursor:pointer;color:var(--text-secondary,#6c757d)}.workflow-page details pre{margin:.5rem 0 0;border:0}.workflow-page .workflow-empty{padding:1.2rem;border:1px dashed var(--frame-border-strong,#cbd5e1);border-radius:.5rem;color:var(--text-secondary,#6c757d);font-size:.84rem}.workflow-page[aria-busy=true]{cursor:progress}.workflow-page[aria-busy=true] button{pointer-events:none;opacity:.65}.workflow-page fieldset{min-width:0}.workflow-page #wf-branches .d-flex{flex-wrap:wrap}.workflow-page #wf-branches .d-flex>.form-select,.workflow-page #wf-branches .d-flex>.form-control{flex:1 1 145px;width:auto;min-width:110px}.workflow-page #wf-branches section{background:color-mix(in srgb,var(--accent) 3%,var(--bg-card));border-color:var(--frame-border-strong)!important}.workflow-page #wf-branches .border{border-color:var(--frame-border-strong)!important}.workflow-page #wf-branches strong{display:inline-block;font-size:.75rem;color:var(--accent);margin-bottom:.25rem}.workflow-page #wf-branches select[multiple]{min-height:76px}.workflow-page .alert{font-size:.84rem;padding:.65rem .85rem}.workflow-page .workflow-readonly{font-size:.8rem;color:var(--text-secondary,#6c757d);margin-bottom:.6rem}[data-bs-theme=dark] .workflow-page .text-muted{color:var(--text-secondary,#adb5bd)!important}@media(max-width:991px){.workflow-page .workflow-sidebar{position:static;max-height:260px}.workflow-page{padding:0}.workflow-page .workflow-heading{flex-wrap:wrap}}@media(max-width:576px){.workflow-page .workflow-heading h1{font-size:1.35rem}.workflow-page .workflow-actions>.btn{flex:1 1 auto}.workflow-page #wf-branches .d-flex>.form-select,.workflow-page #wf-branches .d-flex>.form-control{flex-basis:100%}.workflow-page .card{padding:.75rem!important}}
</style>

View File

@ -1,18 +1,21 @@
{% extends "shared/frontend/base.html" %}
{% set wp = workflow_permissions|default({}) %}
{% block title %}Workflows – BMC Hub{% endblock %}
{% block extra_css %}{% include "workflows/frontend/_style.html" %}{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<h2>Workflows <span id="wf-runtime-pill" class="badge text-bg-secondary fs-6">Henter status…</span></h2><div class="d-flex gap-2 mb-2"><a href="/workflows/operations">Drift og eksport</a><a href="/workflows/resources">Aftaler og klippekort</a><a href="/tags/classification">Tags og klassifikation</a></div><p>Gem en ny version, test den og aktivér den. Tidligere versioner kan testes og aktiveres igen. Aktivering udfører ingen handlinger i sig selv.</p>
<div class="container-fluid workflow-page">
<header class="workflow-heading"><div><div class="workflow-eyebrow">Administration</div><h1>Workflows <span id="wf-runtime-pill" class="badge text-bg-secondary fs-6">Henter status…</span></h1><p>Byg regler, test resultatet og aktivér en gemt version. Tidligere versioner kan aktiveres igen.</p></div></header>
{% include "workflows/frontend/_nav.html" %}
<div id="wf-message" role="status" class="alert d-none"></div>
<div class="row g-3"><aside class="col-lg-3"><button id="wf-new" class="btn btn-primary mb-2">Nyt workflow</button><div id="wf-list" class="list-group"></div></aside>
<main class="col-lg-9"><div class="card p-3">
<div class="row g-3"><aside class="col-lg-3"><div class="card p-3 workflow-sidebar"><h5 class="mb-2">Dine workflows</h5><button id="wf-new" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-primary mb-2">Nyt workflow</button><div id="wf-list" class="list-group"><div class="workflow-empty">Henter workflows…</div></div></div></aside>
<main class="col-lg-9"><div class="card p-3"><h5 class="mb-3">Regler og afregning</h5>{% if not wp.get("manage") %}<p class="workflow-readonly"><i class="bi bi-eye me-1"></i>Du kan se definitioner og historik. Ændringer kræver administrationsadgang.</p>{% endif %}<fieldset {% if not wp.get("manage") %}disabled{% endif %}>
<div class="row g-2"><label class="col-md-5">Navn<input id="wf-name" class="form-control" maxlength="160"></label><label class="col-md-3">Kunde<select id="wf-customer" class="form-select"><option value="">Alle kunder (global)</option></select></label><label class="col-md-2">Prioritet<input id="wf-priority" type="number" min="0" value="100" class="form-control"></label><label class="col-md-2">Trigger<select id="wf-trigger" class="form-select"></select></label></div>
<label class="my-2"><input id="wf-stop" type="checkbox"> Stop efter match</label>
<div id="wf-branches"></div><div class="d-flex gap-2 my-2"><button id="wf-add-branch" class="btn btn-sm btn-outline-secondary">Tilføj ELSE IF</button><button id="wf-add-else" class="btn btn-sm btn-outline-secondary">Tilføj ELSE</button><button id="wf-save" class="btn btn-primary">Gem ny version</button><button id="wf-copy" class="btn btn-outline-secondary">Kopiér</button><button id="wf-disable" class="btn btn-outline-danger">Deaktivér</button></div>
<hr><h5>Versioner og test</h5><select id="wf-versions" class="form-select mb-2"></select>
<div id="wf-branches"></div><div class="workflow-actions my-2"><button id="wf-add-branch" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-sm btn-outline-secondary">Tilføj betingelse</button><button id="wf-add-else" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-sm btn-outline-secondary">Tilføj ellers</button><button id="wf-save" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-primary">Gem ny version</button><button id="wf-copy" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-outline-secondary">Kopiér</button></div>
</fieldset><div class="workflow-actions my-2"><button id="wf-disable" {% if not wp.get("activate") %}hidden disabled{% endif %} class="btn btn-outline-danger">Deaktivér</button></div><hr><h5>Versioner og test</h5><select id="wf-versions" class="form-select mb-2"></select>
<div class="row g-2"><label class="col-md-3">Eksisterende data<select id="wf-entity" class="form-select"><option value="">Indtast testdata</option><option value="case">Sag</option><option value="time_entry">Tidsregistrering</option></select></label><label class="col-md-3">ID<input id="wf-entity-id" type="number" min="1" class="form-control"></label></div>
<div class="row g-2 mt-2"><label class="col-md-4">Testkunde<select id="wf-test-customer" class="form-select"><option value="">Ingen kunde</option></select></label><label class="col-md-2">Timer<input id="wf-test-hours" type="number" min="0" step="0.25" value="1" class="form-control"></label><label class="col-md-6">Beskrivelse<input id="wf-test-description" class="form-control" placeholder="Fx hjælp med Excel"></label></div><p class="small text-muted mt-2">Vælg en eksisterende sag eller tid ovenfor for at teste med de faktiske tags, aftaler og klippekort.</p>
<div class="d-flex gap-2 my-2"><button id="wf-test" class="btn btn-outline-primary">Test gemt version</button><button id="wf-activate" disabled class="btn btn-success">Aktivér testet version</button></div><div id="wf-test-explanation" class="my-2"></div><details><summary>Tekniske testdetaljer</summary><pre id="wf-result" class="bg-light p-2" style="max-height:350px;overflow:auto"></pre></details><h6>Aktiveringshistorik</h6><div id="wf-history"></div>
<div class="workflow-actions my-2"><button id="wf-test" {% if not wp.get("manage") %}hidden disabled{% endif %} class="btn btn-outline-primary">Test gemt version</button><button id="wf-activate" {% if not wp.get("activate") %}hidden disabled{% endif %} disabled class="btn btn-success">Aktivér testet version</button></div><div id="wf-test-explanation" class="my-2"></div><details><summary>Tekniske testdetaljer</summary><pre id="wf-result" class="bg-light p-2" style="max-height:350px;overflow:auto"></pre></details><h6>Aktiveringshistorik</h6><div id="wf-history"></div>
</div></main></div>
</div>
{% endblock %}
@ -21,7 +24,7 @@
(()=>{'use strict';
const $=id=>document.getElementById('wf-'+id); let tagRows=[],customerRows=[],actionOptions={users:[],groups:[],workflows:[],agreements:[],cards:[]};let catalog={}, current=null, version=null, receipt=null, dirty=false;
let definition={trigger:'time.created',stop_after_match:false,branches:[{condition:{all:[{field:'description',operator:'contains',value:''}]},actions:[{type:'set_billing',method:'invoice'}]}]};
const labels={send_to_review:'Send til manuel vurdering',remove_tag:'Fjern automatisk tag',assign_user:'Tildel medarbejder',assign_group:'Tildel gruppe',start_workflow:'Start workflow',create_reminder:'Opret reminder',set_billing:'Vælg afregning',add_tag:'Tilføj tag',suggest_tag:'Foreslå tag',notify:'Notifikation',create_task:'Opret opgave',set_field:'Sæt felt',invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'};
const labels={customer_id:'Kunde',case_id:'Sag',description:'Beskrivelse',hours:'Timer',worked_date:'Arbejdsdato',tags:'Tags',tag_ids:'Tags',status:'Status',billable:'Fakturerbar',entity_type:'Modul',sla_status:'SLA-status',priority:'Prioritet',subject:'Emne',has_active_agreement:'Aktiv fastprisaftale',has_active_card:'Aktivt klippekort',eq:'Er lig med',ne:'Er ikke lig med',contains:'Indeholder',not_contains:'Indeholder ikke',in:'Er en af',not_in:'Er ingen af',gt:'Større end',gte:'Mindst',lt:'Mindre end',lte:'Højst',exists:'Findes',all:'Alle betingelser (OG)',any:'Mindst én betingelse (ELLER)',low:'Lav',normal:'Normal',high:'Høj',urgent:'Haster','time.created':'Tid oprettet','time.updated':'Tid ændret','case.created':'Sag oprettet','case.updated':'Sag ændret','tags.changed':'Tags ændret','mail.received':'Mail modtaget','sla.changed':'SLA ændret','customer.updated':'Kunde ændret','task.updated':'Opgave ændret','reminder.due':'Reminder forfalden','notification.created':'Notifikation oprettet',send_to_review:'Send til manuel vurdering',remove_tag:'Fjern automatisk tag',assign_user:'Tildel medarbejder',assign_group:'Tildel gruppe',start_workflow:'Start workflow',create_reminder:'Opret reminder',set_billing:'Vælg afregning',add_tag:'Tilføj tag',suggest_tag:'Foreslå tag',notify:'Notifikation',create_task:'Opret opgave',set_field:'Sæt felt',invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'};
function message(text,error=false){$('message').textContent=text;$('message').className='alert '+(error?'alert-danger':'alert-info');}
async function api(path='',body){const response=await fetch('/api/v1/workflows'+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await response.json();if(!response.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;}
function changed(){dirty=true;receipt=null;$('activate').disabled=true;}
@ -31,11 +34,11 @@ function input(value,onchange){const el=element('input','form-control form-contr
function button(text,fn){const el=element('button','btn btn-sm btn-outline-secondary',text);el.type='button';el.onclick=()=>{fn();changed();render();};return el;}
function namedSelect(rows,value,onchange,multiple=false){const el=element('select','form-select form-select-sm');el.multiple=multiple;if(!multiple){const placeholder=element('option','','Vælg…');placeholder.value='';el.append(placeholder);}for(const row of rows){const opt=element('option','',row.label||row.name);opt.value=String(row.id);opt.selected=(Array.isArray(value)?value.map(String):[String(value)]).includes(String(row.id));el.append(opt);}el.onchange=()=>{onchange(multiple?[...el.selectedOptions].map(o=>Number(o.value)):(el.value?Number(el.value):null));changed();};return el;}function conditionValue(node){if(['tags','tag_ids'].includes(node.field)){const box=element('div','');box.append(namedSelect(tagRows,node.value,v=>node.value=v,['in','not_in'].includes(node.operator)));const label=element('label','small',' Medtag undertags');const check=element('input');check.type='checkbox';check.checked=!!node.include_descendants;check.onchange=()=>{node.include_descendants=check.checked;changed();};label.prepend(check);box.append(label);return box;}if(node.field==='customer_id')return namedSelect(customerRows,node.value,v=>node.value=v===null?null:Array.isArray(v)?v.map(String):String(v),['in','not_in'].includes(node.operator));return input(node.value,v=>node.value=v);}function readableCondition(node,host,depth=0){if(!node)return;const names={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',worked_date:'Arbejdsdato',status:'Status'},operators={eq:'er',ne:'er ikke',contains:'indeholder',not_contains:'indeholder ikke',in:'er en af',not_in:'er ingen af',gt:'større end',gte:'mindst',lt:'mindre end',lte:'højst',exists:'findes'};let text;if(node.children)text=node.kind==='all'?'Alle betingelser':'Mindst én betingelse';else if(node.kind==='else')text='Ellers';else{const values=Array.isArray(node.expected)?node.expected:[node.expected];const expected=values.map(v=>['tags','tag_ids'].includes(node.field)?tagRows.find(t=>Number(t.id)===Number(v))?.label||v:node.field==='customer_id'?customerRows.find(c=>String(c.id)===String(v))?.name||v:v).join(', ');text=(names[node.field]||node.field)+' '+(operators[node.operator]||node.operator)+' '+expected;}const line=element('div','small',`${node.matched?'✓':'×'} ${text}`);line.style.paddingLeft=(depth*16)+'px';host.append(line);for(const child of node.children||[])readableCondition(child,host,depth+1);}function leaf(){return {field:'description',operator:'contains',value:''};}
function condition(node,replace,remove){const box=element('div','border rounded p-2 my-1');if(node.all||node.any){const key=node.all?'all':'any';box.append(select(['all','any'],key,v=>{node[v]=node[key];if(v!==key)delete node[key];render();}));node[key].forEach((child,i)=>box.append(condition(child,v=>node[key][i]=v,()=>node[key].splice(i,1))));const controls=element('div','d-flex gap-1');controls.append(button('+ Betingelse',()=>node[key].push(leaf())),button('+ Gruppe',()=>node[key].push({all:[leaf()]})));if(remove)controls.append(button('Fjern gruppe',remove));box.append(controls);}else{const row=element('div','d-flex gap-1');row.append(select(catalog.fields,node.field,v=>{node.field=v;if(['tags','tag_ids'].includes(v))node.operator='contains';render();}),select(['tags','tag_ids'].includes(node.field)?['contains','not_contains','exists']:catalog.operators,node.operator,v=>{node.operator=v;render();}),conditionValue(node),button('Gruppér',()=>replace({all:[node]})));if(remove)row.append(button('Fjern',remove));box.append(row);}return box;}
function render(){const root=$('branches');root.replaceChildren();definition.branches.forEach((branch,i)=>{const box=element('section','border rounded p-2 mb-2');box.append(element('strong','',branch.condition===null?'ELSE':i===0?'IF':'ELSE IF'));if(branch.condition)box.append(condition(branch.condition,v=>branch.condition=v));branch.actions.forEach((action,j)=>{const row=element('div','d-flex gap-1 my-1');row.append(select(catalog.actions,action.type,v=>{branch.actions[j]=v==='set_billing'?{type:v,method:'invoice'}:v==='set_field'?{type:v,field:'priority',value:'normal'}:{type:v};render();}));if(action.type==='set_billing'){row.append(select(catalog.billing_methods,action.method,v=>action.method=v));if(action.method==='subscription'||action.method==='prepaid'){const key=action.method==='subscription'?'agreement_id':'card_id',pool=action.method==='subscription'?actionOptions.agreements:actionOptions.cards;const scoped=$('customer').value?pool.filter(r=>String(r.customer_id)===$('customer').value):pool;row.append(namedSelect(scoped,action[key],v=>{if(v===null)delete action[key];else action[key]=v;}));}if(action.method==='invoice'){const product=input(action.product_number||'',v=>{if(v==='')delete action.product_number;else action.product_number=v;});product.placeholder='Varenummer (valgfrit)';row.append(product);}}else if(['add_tag','remove_tag','suggest_tag'].includes(action.type)){row.append(namedSelect(tagRows,action.tag_id,v=>action.tag_id=v));}else if(action.type==='set_field'){row.append(select(['priority','status'],action.field||'priority',v=>action.field=v),input(action.value,v=>action.value=v));}else if(['assign_user','assign_group','start_workflow'].includes(action.type)){const key={assign_user:'user_id',assign_group:'group_id',start_workflow:'workflow_id'}[action.type];const pool={assign_user:actionOptions.users,assign_group:actionOptions.groups,start_workflow:actionOptions.workflows}[action.type];row.append(namedSelect(pool,action[key],v=>action[key]=v));}else if(action.type==='send_to_review'){const reason=input(action.reason||'',v=>action.reason=v);reason.placeholder='Begrundelse (valgfri)';row.append(reason);}else{const title=input(action.title||'',v=>action.title=v);title.placeholder='Titel';row.append(title);if(['notify','create_reminder'].includes(action.type)){row.append(namedSelect(actionOptions.users,action.recipient_user_ids||[],v=>action.recipient_user_ids=v,true));}if(action.type==='create_reminder'){const due=element('input','form-control form-control-sm');due.type='datetime-local';if(action.scheduled_at){const date=new Date(action.scheduled_at);if(!Number.isNaN(date.getTime()))due.value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);}due.onchange=()=>{if(due.value)action.scheduled_at=new Date(due.value).toISOString();else delete action.scheduled_at;changed();};row.append(due);}}row.append(button('Fjern handling',()=>branch.actions.splice(j,1)));box.append(row);});box.append(button('+ Handling',()=>branch.actions.push({type:'set_billing',method:'invoice'})));if(definition.branches.length>1)box.append(button('Fjern gren',()=>definition.branches.splice(i,1)));root.append(box);});}
function render(){const root=$('branches');root.replaceChildren();definition.branches.forEach((branch,i)=>{const box=element('section','border rounded p-2 mb-2');box.append(element('strong','',branch.condition===null?'Ellers':i===0?'Hvis':'Ellers hvis'));if(branch.condition)box.append(condition(branch.condition,v=>branch.condition=v));branch.actions.forEach((action,j)=>{const row=element('div','d-flex gap-1 my-1');row.append(select(catalog.actions,action.type,v=>{branch.actions[j]=v==='set_billing'?{type:v,method:'invoice'}:v==='set_field'?{type:v,field:'priority',value:'normal'}:{type:v};render();}));if(action.type==='set_billing'){row.append(select(catalog.billing_methods,action.method,v=>action.method=v));if(action.method==='subscription'||action.method==='prepaid'){const key=action.method==='subscription'?'agreement_id':'card_id',pool=action.method==='subscription'?actionOptions.agreements:actionOptions.cards;const scoped=$('customer').value?pool.filter(r=>String(r.customer_id)===$('customer').value):pool;row.append(namedSelect(scoped,action[key],v=>{if(v===null)delete action[key];else action[key]=v;}));}if(action.method==='invoice'){const product=input(action.product_number||'',v=>{if(v==='')delete action.product_number;else action.product_number=v;});product.placeholder='Varenummer (valgfrit)';row.append(product);}}else if(['add_tag','remove_tag','suggest_tag'].includes(action.type)){row.append(namedSelect(tagRows,action.tag_id,v=>action.tag_id=v));}else if(action.type==='set_field'){row.append(select(['priority','status'],action.field||'priority',v=>action.field=v),input(action.value,v=>action.value=v));}else if(['assign_user','assign_group','start_workflow'].includes(action.type)){const key={assign_user:'user_id',assign_group:'group_id',start_workflow:'workflow_id'}[action.type];const pool={assign_user:actionOptions.users,assign_group:actionOptions.groups,start_workflow:actionOptions.workflows}[action.type];row.append(namedSelect(pool,action[key],v=>action[key]=v));}else if(action.type==='send_to_review'){const reason=input(action.reason||'',v=>action.reason=v);reason.placeholder='Begrundelse (valgfri)';row.append(reason);}else{const title=input(action.title||'',v=>action.title=v);title.placeholder='Titel';row.append(title);if(['notify','create_reminder'].includes(action.type)){row.append(namedSelect(actionOptions.users,action.recipient_user_ids||[],v=>action.recipient_user_ids=v,true));}if(action.type==='create_reminder'){const due=element('input','form-control form-control-sm');due.type='datetime-local';if(action.scheduled_at){const date=new Date(action.scheduled_at);if(!Number.isNaN(date.getTime()))due.value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);}due.onchange=()=>{if(due.value)action.scheduled_at=new Date(due.value).toISOString();else delete action.scheduled_at;changed();};row.append(due);}}row.append(button('Fjern handling',()=>branch.actions.splice(j,1)));box.append(row);});box.append(button('+ Handling',()=>branch.actions.push({type:'set_billing',method:'invoice'})));if(definition.branches.length>1)box.append(button('Fjern gren',()=>definition.branches.splice(i,1)));root.append(box);});}
function loadVersion(v){version=v;definition=structuredClone(v.definition);const meta=definition.metadata||{};$('name').value=meta.name||current.name;$('customer').value=meta.customer_id??current.customer_id??'';$('priority').value=meta.priority??current.priority;$('trigger').value=definition.trigger;$('stop').checked=!!definition.stop_after_match;dirty=false;receipt=null;$('activate').disabled=true;render();}
async function open(id){current=await api('/'+id);$('versions').replaceChildren();current.versions.forEach(v=>{const opt=element('option','',`Version ${v.version}${current.active_version_id===v.id?' (aktiv)':''}`);opt.value=v.id;$('versions').append(opt);});loadVersion(current.versions[0]);$('history').replaceChildren();current.activation_history.forEach(h=>$('history').append(element('div','small',`${h.created_at} · version-ID ${h.version_id??'ingen'} · bruger ${h.created_by}`)));}
async function list(){const rows=await api();$('list').replaceChildren();rows.forEach(row=>{const btn=element('button','list-group-item list-group-item-action',row.name+(row.enabled?' · aktiv':' · inaktiv'));btn.onclick=()=>guard(()=>open(row.id));$('list').append(btn);});}
async function guard(fn){try{await fn();}catch(error){message(error.message,true);}}
async function list(){const rows=await api();$('list').replaceChildren();if(!rows.length)$('list').append(element('div','workflow-empty','Ingen workflows endnu. Opret et workflow for at komme i gang.'));rows.forEach(row=>{const btn=element('button','list-group-item list-group-item-action',row.name+(row.enabled?' · aktiv':' · inaktiv'));btn.onclick=()=>guard(async()=>{await open(row.id);for(const item of $('list').children)item.classList.remove('active');btn.classList.add('active');});$('list').append(btn);});}
let requestBusy=false;async function guard(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(error){message(error.message,true);}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}}
['name','customer','priority','trigger','stop'].forEach(id=>$(id).onchange=changed);
$('new').onclick=()=>{current=null;version=null;definition={trigger:'time.created',stop_after_match:false,branches:[{condition:{all:[leaf()]},actions:[{type:'set_billing',method:'invoice'}]}]};$('name').value='';$('customer').value='';$('priority').value=100;$('versions').replaceChildren();$('history').replaceChildren();$('result').textContent='';changed();render();};
$('add-branch').onclick=()=>{const index=definition.branches.findIndex(b=>b.condition===null);definition.branches.splice(index<0?definition.branches.length:index,0,{condition:{all:[leaf()]},actions:[{type:'set_billing',method:'invoice'}]});changed();render();};
@ -46,7 +49,7 @@ $('test').onclick=()=>guard(async()=>{if(!current||dirty)throw Error('Gem ændri
$('activate').onclick=()=>guard(async()=>{if(!receipt||dirty)throw Error('Test versionen først');await api('/'+current.id+'/activate',{version_id:version.id,simulation_receipt:receipt});await list();await open(current.id);message('Den valgte version er aktiveret. Motorens featureflag styrer behandling.');});
$('disable').onclick=()=>guard(async()=>{if(!current)throw Error('Vælg et workflow');await api('/'+current.id+'/disable',{});await list();await open(current.id);message('Workflow deaktiveret.');});
$('copy').onclick=()=>guard(async()=>{if(!current)throw Error('Gem workflowet først');const row=await api('/'+current.id+'/copy',{name:($('name').value||current.name)+' – kopi'});await list();await open(row.id);});
guard(async()=>{const runtime=await api('/runtime/status');$('runtime-pill').textContent=!runtime.enabled?'Slukket':runtime.mode==='shadow'?'Skyggetilstand':'Aktiv';$('runtime-pill').className='badge fs-6 '+(!runtime.enabled?'text-bg-secondary':runtime.mode==='shadow'?'text-bg-info':'text-bg-warning');catalog=await api('/catalog');const customers=await api('/customers');customerRows=customers;tagRows=await api('/tags');actionOptions=await api('/options');const tagMap=new Map(tagRows.map(t=>[t.id,t]));function tagPath(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const parent=tagMap.get(t.parent_id);return parent?tagPath(parent,seen)+' › '+t.name:t.name;}tagRows=tagRows.map(t=>({...t,label:tagPath(t)}));customers.forEach(c=>{const opt=element('option','',c.name);opt.value=String(c.id);$('customer').append(opt);$('test-customer').append(opt.cloneNode(true));});catalog.triggers.forEach(v=>{const opt=element('option','',v);opt.value=v;$('trigger').append(opt);});$('trigger').value=definition.trigger;render();await list();});
guard(async()=>{const runtime=await api('/runtime/status');$('runtime-pill').textContent=!runtime.enabled?'Slukket':runtime.mode==='shadow'?'Skyggetilstand':'Aktiv';$('runtime-pill').className='badge fs-6 '+(!runtime.enabled?'text-bg-secondary':runtime.mode==='shadow'?'text-bg-info':'text-bg-warning');catalog=await api('/catalog');const customers=await api('/customers');customerRows=customers;tagRows=await api('/tags');actionOptions=await api('/options');const tagMap=new Map(tagRows.map(t=>[t.id,t]));function tagPath(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const parent=tagMap.get(t.parent_id);return parent?tagPath(parent,seen)+' › '+t.name:t.name;}tagRows=tagRows.map(t=>({...t,label:tagPath(t)}));customers.forEach(c=>{const opt=element('option','',c.name);opt.value=String(c.id);$('customer').append(opt);$('test-customer').append(opt.cloneNode(true));});catalog.triggers.forEach(v=>{const opt=element('option','',labels[v]||v);opt.value=v;$('trigger').append(opt);});$('trigger').value=definition.trigger;render();await list();});
})();
</script>
{% endblock %}

View File

@ -1,20 +1,22 @@
{% extends "shared/frontend/base.html" %}
{% set wp = workflow_permissions|default({}) %}
{% block title %}Afregningsbeslutning – BMC Hub{% endblock %}
{% block extra_css %}{% include "workflows/frontend/_style.html" %}{% endblock %}
{% block content %}
<div class="container py-3"><h2>Afregningsbeslutning · tid #{{ time_id }}</h2><div id="decision-message" role="status"></div><div id="decision-summary" class="alert alert-info"></div>
<div class="container-fluid workflow-page"><header class="workflow-heading"><div><div class="workflow-eyebrow">Økonomi · tidsregistrering #{{ time_id }}</div><h1>Afregningsbeslutning</h1><p>Se hvorfor tiden har fået sin afregning, og følg tidligere beslutninger.</p></div></header>{% include "workflows/frontend/_nav.html" %}<div id="decision-message" role="status"></div><div id="decision-summary" class="alert alert-info" aria-live="polite">Henter afregningsbeslutningen…</div>
<h5>Hvorfor?</h5><div id="decision-explanation" class="mb-3"></div><details><summary>Tekniske detaljer</summary><pre id="decision-details" class="bg-light p-3" style="max-height:400px;overflow:auto"></pre></details>
<div class="card p-3"><h5>Manuel overstyring</h5><p>Overstyring ændrer kun beslutningen. Bogholderiets godkendelse udfører afregningen.</p>
<div class="card p-3" {% if not wp.get("override") %}hidden{% endif %}><h5>Manuel overstyring</h5><p>Overstyring ændrer kun beslutningen. Bogholderiets godkendelse udfører afregningen.</p>
<div class="row g-2"><label class="col-md-4">Metode<select id="decision-method" class="form-select"><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></select></label><label class="col-md-3">Aftale-ID<input id="decision-agreement" type="number" min="1" class="form-control"></label><label class="col-md-3">Klippekort-ID<input id="decision-card" type="number" min="1" class="form-control"></label></div>
<label class="d-block mt-2">Varenummer<input id="decision-product" class="form-control"></label><label class="d-block mt-2">Begrundelse<textarea id="decision-reason" class="form-control" maxlength="2000"></textarea></label>
<div class="d-flex gap-2 my-2"><button id="decision-override" class="btn btn-primary">Gem overstyring</button><button id="decision-recalculate" class="btn btn-outline-secondary">Genberegn (bevar overstyring)</button><button id="decision-reset" class="btn btn-outline-danger">Ophæv overstyring og genberegn</button></div></div>
<h5 class="mt-3">Historik</h5><div id="decision-history"></div></div>
<div class="workflow-actions my-2"><button id="decision-override" class="btn btn-primary">Gem overstyring</button><button id="decision-recalculate" class="btn btn-outline-secondary">Genberegn (bevar overstyring)</button><button id="decision-reset" {% if not wp.get("activate") %}hidden disabled{% endif %} class="btn btn-outline-danger">Ophæv overstyring og genberegn</button></div></div>
<h5 class="mt-3">Historik</h5><div id="decision-history"><div class="workflow-empty">Henter historik…</div></div></div>
{% endblock %}
{% block extra_js %}<script>
(()=>{'use strict';const methodNames={invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'},reasons={matched_rule:'En regel valgte afregningen.',manual_override:'Afregningen er valgt manuelt.',no_matching_rule:'Ingen regel valgte en afregningsmetode.',conflicting_rules:'Flere regler valgte forskellige metoder. Bogholderiet skal gennemgå tiden.',pending_tag_approval:'Afventer godkendelse af tags, der kan påvirke afregningen.',insufficient_card_balance:'Klippekortet har ikke timer nok.',ambiguous_or_missing_cards:'Klippekort mangler eller kan ikke vælges entydigt.',ambiguous_or_missing_agreements:'Fastprisaftale mangler eller kan ikke vælges entydigt.'};function explain(result){$('explanation').replaceChildren();const p=document.createElement('p');p.textContent=reasons[result.reason]||'Beslutningen skal gennemgås.';$('explanation').append(p);if(result.override_reason){const p=document.createElement('p');p.textContent='Begrundelse: '+result.override_reason;$('explanation').append(p);}for(const trace of result.trace||[]){const p=document.createElement('div');p.className='small';p.textContent=`Workflow #${trace.workflow_id}, gren ${trace.branch+1}: ${trace.matched?'betingelser opfyldt':'betingelser ikke opfyldt'}`;$('explanation').append(p);const walk=(node,depth=0)=>{if(!node)return;const line=document.createElement('div');line.className='small';line.style.paddingLeft=(depth*16)+'px';const fields={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',status:'Status'};line.textContent=(node.matched?'✓ ':'× ')+(node.children?(node.kind==='all'?'Alle betingelser':'Mindst én betingelse'):node.kind==='else'?'Ellers':(fields[node.field]||node.field));$('explanation').append(line);for(const child of node.children||[])walk(child,depth+1);};walk(trace.condition);}}const id={{ time_id|tojson }},$=name=>document.getElementById('decision-'+name),base='/api/v1/timetracking/time/'+id;
(()=>{'use strict';const stateNames={decided:'Valgt',manual_review:'Manuel vurdering',overridden:'Overstyret',settled:'Afregnet',shadow:'Skyggetilstand',active:'Aktiv'};const methodNames={invoice:'Faktura',subscription:'Fastpris',prepaid:'Klippekort',internal:'Intern tid',non_billable:'Ikke fakturerbar'},reasons={matched_rule:'En regel valgte afregningen.',manual_override:'Afregningen er valgt manuelt.',no_matching_rule:'Ingen regel valgte en afregningsmetode.',conflicting_rules:'Flere regler valgte forskellige metoder. Bogholderiet skal gennemgå tiden.',pending_tag_approval:'Afventer godkendelse af tags, der kan påvirke afregningen.',insufficient_card_balance:'Klippekortet har ikke timer nok.',ambiguous_or_missing_cards:'Klippekort mangler eller kan ikke vælges entydigt.',ambiguous_or_missing_agreements:'Fastprisaftale mangler eller kan ikke vælges entydigt.'};function explain(result){$('explanation').replaceChildren();const p=document.createElement('p');p.textContent=reasons[result.reason]||'Beslutningen skal gennemgås.';$('explanation').append(p);if(result.override_reason){const p=document.createElement('p');p.textContent='Begrundelse: '+result.override_reason;$('explanation').append(p);}for(const trace of result.trace||[]){const p=document.createElement('div');p.className='small';p.textContent=`Workflow #${trace.workflow_id}, gren ${trace.branch+1}: ${trace.matched?'betingelser opfyldt':'betingelser ikke opfyldt'}`;$('explanation').append(p);const walk=(node,depth=0)=>{if(!node)return;const line=document.createElement('div');line.className='small';line.style.paddingLeft=(depth*16)+'px';const fields={customer_id:'Kunde',description:'Beskrivelse',hours:'Timer',tags:'Tags',tag_ids:'Tags',status:'Status'};line.textContent=(node.matched?'✓ ':'× ')+(node.children?(node.kind==='all'?'Alle betingelser':'Mindst én betingelse'):node.kind==='else'?'Ellers':(fields[node.field]||node.field));$('explanation').append(line);for(const child of node.children||[])walk(child,depth+1);};walk(trace.condition);}}const id={{ time_id|tojson }},$=name=>document.getElementById('decision-'+name),base='/api/v1/timetracking/time/'+id;
async function api(path,body){const r=await fetch(base+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;}
function message(text,error=false){$('message').className='alert '+(error?'alert-danger':'alert-info');$('message').textContent=text;}
async function load(){const data=await api('/workflow-decision');const d=data.current,shadow=!d&&data.executions?.[0];$('summary').textContent=d?`Metode: ${methodNames[d.method]||'Manuel vurdering'} · status: ${d.status}${d.manual_override?' · manuelt overstyret':''}${data.stale?' · forældet beslutning':''}`:shadow?'Skyggetilstand · foreslået metode: '+(shadow.result.method||'Manuel vurdering')+' · ingen afregning ændret':'Ingen beslutning endnu';explain(d?.details||shadow?.result||{});$('details').textContent=JSON.stringify(d?.details||shadow?.result||{},null,2);$('history').replaceChildren();[...data.history,...(data.executions||[]).map(x=>({created_at:x.created_at,method:x.result.method,status:x.mode,details:x.result,is_current:false}))].forEach(row=>{const detail=document.createElement('details'),summary=document.createElement('summary'),pre=document.createElement('pre');summary.textContent=`${row.created_at} · ${row.method||'vurdering'} · ${row.status}${row.is_current?' · aktuel':''}`;pre.textContent=JSON.stringify(row.details,null,2);detail.append(summary,pre);$('history').append(detail);});}
async function run(fn){try{await fn();await load();}catch(e){message(e.message,true);}}
async function load(){const data=await api('/workflow-decision');const d=data.current,shadow=!d&&data.executions?.[0];$('summary').textContent=d?`Metode: ${methodNames[d.method]||'Manuel vurdering'} · status: ${stateNames[d.status]||d.status}${d.manual_override?' · manuelt overstyret':''}${data.stale?' · forældet beslutning':''}`:shadow?'Skyggetilstand · foreslået metode: '+(methodNames[shadow.result.method]||'Manuel vurdering')+' · ingen afregning ændret':'Ingen beslutning endnu';explain(d?.details||shadow?.result||{});$('details').textContent=JSON.stringify(d?.details||shadow?.result||{},null,2);$('history').replaceChildren();if(!data.history.length&&!data.executions?.length)$('history').append(Object.assign(document.createElement('div'),{className:'workflow-empty',textContent:'Der er ingen tidligere beslutninger.'}));[...data.history,...(data.executions||[]).map(x=>({created_at:x.created_at,method:x.result.method,status:x.mode,details:x.result,is_current:false}))].forEach(row=>{const detail=document.createElement('details'),summary=document.createElement('summary'),pre=document.createElement('pre');summary.textContent=`${row.created_at} · ${methodNames[row.method]||'Manuel vurdering'} · ${stateNames[row.status]||row.status}${row.is_current?' · aktuel':''}`;pre.textContent=JSON.stringify(row.details,null,2);detail.append(summary,pre);$('history').append(detail);});}
let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();await load();}catch(e){message(e.message,true);}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}}
$('override').onclick=()=>run(async()=>{const body={method:$('method').value,reason:$('reason').value};[['agreement','agreement_id'],['card','card_id'],['product','product_number']].forEach(([field,key])=>{if($(field).value)body[key]=key==='product_number'?$(field).value:Number($(field).value);});await api('/workflow-override',body);message('Overstyring gemt med begrundelse.');});
$('recalculate').onclick=()=>run(async()=>{const data=await api('/workflow-recalculate',{});message(data.preserved_override?'Den manuelle overstyring er bevaret.':'Beslutningen er genberegnet.');});
$('reset').onclick=()=>run(async()=>{await api('/workflow-recalculate',{reset_override:true,reason:$('reason').value});message('Overstyringen er ophævet; ny beslutning er gemt.');});run(load);

View File

@ -1,12 +1,14 @@
{% extends "shared/frontend/base.html" %}
{% set wp = workflow_permissions|default({}) %}
{% block title %}Workflowdrift – BMC Hub{% endblock %}
{% block content %}<div class="container py-3"><h2>Workflowdrift</h2><a href="/workflows">Tilbage til workflows</a><button id="ops-refresh" class="btn btn-sm btn-outline-secondary ms-3">Opdatér</button><div id="ops-message" role="status" class="my-2"></div><div id="ops-runtime" class="alert alert-info mt-3"></div><h5>Hændelseskø</h5><div id="ops-queues" class="mb-3"></div><h5>Fejlede hændelser</h5><div id="ops-failed"></div><h5 class="mt-4">Eksport og afstemning</h5><p>Ved en uafklaret eksport: find den eksisterende ordre i e-conomic og indtast ordrenummeret. Afstemning læser og kontrollerer ordren; den opretter ingen ny ordre.</p><div id="ops-exports"></div></div>{% endblock %}
{% block extra_css %}{% include "workflows/frontend/_style.html" %}{% endblock %}
{% block content %}<div class="container-fluid workflow-page"><header class="workflow-heading"><div><div class="workflow-eyebrow">Administration · drift</div><h1>Workflowdrift</h1><p>Følg hændelser og afstem uafklarede eksporter.</p></div><button id="ops-refresh" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></header>{% include "workflows/frontend/_nav.html" %}<div id="ops-message" role="status" class="my-2"></div><div id="ops-runtime" class="alert alert-info mt-3" aria-live="polite">Henter motorstatus…</div><h5>Hændelseskø</h5><div id="ops-queues" class="mb-3"><div class="workflow-empty">Henter hændelseskø…</div></div><h5>Fejlede hændelser</h5><div id="ops-failed"></div><h5 class="mt-4">Eksport og afstemning</h5><p>Ved en uafklaret eksport: find den eksisterende ordre i e-conomic og indtast ordrenummeret. Afstemning læser og kontrollerer ordren; den opretter ingen ny ordre.</p><div id="ops-exports"></div></div>{% endblock %}
{% block extra_js %}<script>
(()=>{'use strict';const $=name=>document.getElementById('ops-'+name),statusLabels={pending:'Afventer',processing:'Behandles',done:'Færdig',failed:'Fejlet',superseded:'Erstattet',sending:'Sendes',uncertain:'Uafklaret'};
(()=>{'use strict';const canManage={{ wp.get('manage',false)|tojson }},canExport={{ wp.get('economic_export',false)|tojson }};const $=name=>document.getElementById('ops-'+name),statusLabels={pending:'Afventer',processing:'Behandles',done:'Færdig',failed:'Fejlet',superseded:'Erstattet',sending:'Sendes',uncertain:'Uafklaret'};
function el(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e;}
async function api(path,body){const r=await fetch('/api/v1/workflows'+path,{method:body?'POST':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(typeof data.detail==='string'?data.detail:JSON.stringify(data.detail));return data;}
async function run(fn){try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}}
async function load(){const runtime=await api('/runtime/status');$('runtime').className='alert '+(!runtime.enabled?'alert-secondary':runtime.mode==='shadow'?'alert-info':'alert-warning');$('runtime').textContent=!runtime.enabled?'Workflowmotoren er slukket. Ingen nye workflows behandles.':`Motoren kører i ${runtime.mode==='shadow'?'skyggetilstand – ingen afregning ændres':'aktiv tilstand'}. Aktive kunder: ${(runtime.customer_ids||[]).join(', ')||'ingen'}${runtime.internal_enabled?' · Intern tid aktiveret':''} · AI: ${runtime.ai_enabled?'til':'fra'}`;$('queues').replaceChildren();for(const q of runtime.queues||[])$('queues').append(el('div',`${statusLabels[q.status]||q.status}: ${q.count}${q.oldest?' · ældste '+q.oldest:''}`));$('failed').replaceChildren();if(!runtime.failed?.length)$('failed').append(el('p','Ingen fejlede hændelser.','text-muted'));for(const f of runtime.failed||[]){const card=el('section',undefined,'card p-3 my-2');card.append(el('strong',`${f.trigger} · ${f.entity_type} #${f.entity_id}`),el('p',f.error||'Ukendt fejl','text-danger'),el('span',`Forsøg: ${f.attempts} · ${f.created_at}`,'small text-muted'));const retry=el('button','Genforsøg','btn btn-sm btn-outline-primary mt-2');retry.disabled=!runtime.enabled;retry.onclick=()=>run(async()=>{retry.disabled=true;try{await api('/runtime/events/'+encodeURIComponent(f.id)+'/retry',{});$('message').className='alert alert-success';$('message').textContent='Et nyt genforsøg er lagt i kø. Den tidligere hændelse bevares.';await load();}finally{retry.disabled=!runtime.enabled;}});card.append(retry);$('failed').append(card);}await loadExports();}
async function loadExports(){try{const rows=await api('/exports');$('exports').replaceChildren();if(!rows.length)$('exports').append(el('p','Ingen workfloweksporter registreret.','text-muted'));for(const row of rows){const card=el('section',undefined,'card p-3 my-2'),link=el('a','Åbn ordrekladde #'+row.draft_id);link.href='/ordre/'+Number(row.draft_id);card.append(link,el('p',`Status: ${statusLabels[row.status]||row.status} · Reference: ${row.reference}`));if(row.error)card.append(el('p',row.error,'text-danger'));if(['sending','uncertain'].includes(row.status)){const label=el('label','Eksisterende e-conomic-ordrenummer'),input=el('input',undefined,'form-control');input.type='number';input.min='1';label.append(input);card.append(label);const btn=el('button','Kontrollér og afstem eksisterende ordre','btn btn-outline-primary mt-2');btn.onclick=()=>run(async()=>{if(!Number.isInteger(Number(input.value))||Number(input.value)<=0)throw Error('Angiv et gyldigt ordrenummer');btn.disabled=true;try{await api('/exports/'+Number(row.draft_id)+'/reconcile',{economic_order_number:Number(input.value)});$('message').className='alert alert-success';$('message').textContent='Den eksisterende ordre er verificeret og knyttet til kladden.';await loadExports();}finally{btn.disabled=false;}});card.append(btn);}$('exports').append(card);}}catch(e){$('exports').replaceChildren(el('p',e.message,'text-muted'));}}
let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}}
async function load(){const runtime=await api('/runtime/status');$('runtime').className='alert '+(!runtime.enabled?'alert-secondary':runtime.mode==='shadow'?'alert-info':'alert-warning');$('runtime').textContent=!runtime.enabled?'Workflowmotoren er slukket. Ingen nye workflows behandles.':`Motoren kører i ${runtime.mode==='shadow'?'skyggetilstand – ingen afregning ændres':'aktiv tilstand'}. Aktive kunder: ${(runtime.customer_ids||[]).join(', ')||'ingen'}${runtime.internal_enabled?' · Intern tid aktiveret':''} · AI: ${runtime.ai_enabled?'til':'fra'}`;$('queues').replaceChildren();if(!runtime.queues?.length)$('queues').append(el('div','Hændelseskøen er tom.','workflow-empty'));for(const q of runtime.queues||[])$('queues').append(el('div',`${statusLabels[q.status]||q.status}: ${q.count}${q.oldest?' · ældste '+q.oldest:''}`));$('failed').replaceChildren();if(!runtime.failed?.length)$('failed').append(el('p','Ingen fejlede hændelser.','text-muted'));for(const f of runtime.failed||[]){const card=el('section',undefined,'card p-3 my-2');card.append(el('strong',`${f.trigger} · ${f.entity_type} #${f.entity_id}`),el('p',f.error||'Ukendt fejl','text-danger'),el('span',`Forsøg: ${f.attempts} · ${f.created_at}`,'small text-muted'));const retry=el('button','Genforsøg','btn btn-sm btn-outline-primary mt-2');retry.disabled=!runtime.enabled;retry.onclick=()=>run(async()=>{retry.disabled=true;try{await api('/runtime/events/'+encodeURIComponent(f.id)+'/retry',{});$('message').className='alert alert-success';$('message').textContent='Et nyt genforsøg er lagt i kø. Den tidligere hændelse bevares.';await load();}finally{retry.disabled=!runtime.enabled;}});if(canManage)card.append(retry);$('failed').append(card);}await loadExports();}
async function loadExports(){if(!canExport){$('exports').replaceChildren(el('div','Eksportoversigten kræver adgang til e-conomic-eksport.','workflow-empty'));return;}try{const rows=await api('/exports');$('exports').replaceChildren();if(!rows.length)$('exports').append(el('p','Ingen workfloweksporter registreret.','text-muted'));for(const row of rows){const card=el('section',undefined,'card p-3 my-2'),link=el('a','Åbn ordrekladde #'+row.draft_id);link.href='/ordre/'+Number(row.draft_id);card.append(link,el('p',`Status: ${statusLabels[row.status]||row.status} · Reference: ${row.reference}`));if(row.error)card.append(el('p',row.error,'text-danger'));if(canManage&&['sending','uncertain'].includes(row.status)){const label=el('label','Eksisterende e-conomic-ordrenummer'),input=el('input',undefined,'form-control');input.type='number';input.min='1';label.append(input);card.append(label);const btn=el('button','Kontrollér og afstem eksisterende ordre','btn btn-outline-primary mt-2');btn.onclick=()=>run(async()=>{if(!Number.isInteger(Number(input.value))||Number(input.value)<=0)throw Error('Angiv et gyldigt ordrenummer');btn.disabled=true;try{await api('/exports/'+Number(row.draft_id)+'/reconcile',{economic_order_number:Number(input.value)});$('message').className='alert alert-success';$('message').textContent='Den eksisterende ordre er verificeret og knyttet til kladden.';await loadExports();}finally{btn.disabled=false;}});card.append(btn);}$('exports').append(card);}}catch(e){$('exports').replaceChildren(el('p',e.message,'text-muted'));}}
$('refresh').onclick=()=>run(load);run(load);
})();</script>{% endblock %}

View File

@ -1,12 +1,14 @@
{% extends "shared/frontend/base.html" %}
{% set wp = workflow_permissions|default({}) %}
{% block title %}Aftaledækning – BMC Hub{% endblock %}
{% block content %}<div class="container py-3"><h2>Aftaler og klippekort</h2><p>Vælg hvilke tags arbejdet skal have, og hvilke der udelukker dækning. Et tomt udvalg dækker alle tags. Ændringer gemmes med historik.</p><a href="/workflows">Tilbage til workflows</a><div id="resource-message" role="status" class="my-2"></div><label class="d-block my-3">Kunde<select id="resource-customer" class="form-select"><option value="">Alle kunder</option></select></label><div id="resource-list"></div></div>{% endblock %}
{% block extra_css %}{% include "workflows/frontend/_style.html" %}{% endblock %}
{% block content %}<div class="container-fluid workflow-page"><header class="workflow-heading"><div><div class="workflow-eyebrow">Administration · afregning</div><h1>Aftaler og klippekort</h1><p>Vælg hvilke tags en aftale dækker. Gemte ændringer bevares i historikken.</p></div></header>{% include "workflows/frontend/_nav.html" %}<div id="resource-message" role="status" class="my-2"></div><label class="d-block my-3">Kunde<select id="resource-customer" class="form-select" style="max-width:420px"><option value="">Alle kunder</option></select></label><div id="resource-list"><div class="workflow-empty">Henter aftaler og klippekort…</div></div></div>{% endblock %}
{% block extra_js %}<script>
(()=>{'use strict';const $=id=>document.getElementById('resource-'+id);let tags=[],customers=[];
async function api(path,body){const r=await fetch('/api/v1/workflows'+path,{method:body?'PUT':'GET',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok)throw Error(typeof d.detail==='string'?d.detail:JSON.stringify(d.detail));return d;}
function el(tag,text,cls){const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;}
function choices(selected){const e=el('select','','form-select');e.multiple=true;e.size=6;for(const t of tags){const o=el('option',t.label||t.name);o.value=t.id;o.selected=(selected||[]).includes(t.id);e.append(o);}return e;}
async function load(){const data=await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();for(const [kind,rows] of [['agreements',data.agreements],['cards',data.cards]])for(const row of rows){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris':'Klippekort'} ${row.agreement_number||row.card_number||row.id} · ${name}`),el('p','Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt med historik.';await load();});card.append(save);$('list').append(card);}}
async function run(fn){try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}}
async function load(){const data=await api('/resources'+($('customer').value?'?customer_id='+encodeURIComponent($('customer').value):''));$('list').replaceChildren();if(!data.agreements.length&&!data.cards.length)$('list').append(el('div','Ingen aftaler eller klippekort for det valgte firma.','workflow-empty'));for(const [kind,rows] of [['agreements',data.agreements],['cards',data.cards]])for(const row of rows){const coverage=row.workflow_coverage||{},card=el('section','','card p-3 my-3'),name=customers.find(c=>Number(c.id)===Number(row.customer_id))?.name||'Ukendt kunde';card.append(el('h5',`${kind==='agreements'?'Fastpris':'Klippekort'} ${row.agreement_number||row.card_number||row.id} · ${name}`),el('p','Status: '+row.status,'small text-muted'));const include=choices(coverage.tag_ids),exclude=choices(coverage.excluded_tag_ids),grid=el('div','','row g-2');for(const [title,control] of [['Dækker et af disse tags · tomt betyder alle',include],['Udelukker disse tags',exclude]]){const label=el('label',title,'col-md-6');label.append(control);grid.append(label);}card.append(grid);const label=el('label',' Medtag undertags','my-2'),descendants=el('input');descendants.type='checkbox';descendants.checked=!!coverage.include_descendants;label.prepend(descendants);card.append(label);let price=null;if(kind==='agreements'){const label=el('label','Månedspris (tom = eksisterende beregning)','d-block');price=el('input','','form-control');price.type='number';price.min='0';price.step='0.01';price.value=row.monthly_price??'';label.append(price);card.append(label);}const save=el('button','Gem dækning','btn btn-primary mt-2');save.onclick=()=>run(async()=>{const payload={workflow_coverage:{tag_ids:[...include.selectedOptions].map(o=>Number(o.value)),excluded_tag_ids:[...exclude.selectedOptions].map(o=>Number(o.value)),include_descendants:descendants.checked}};if(price)payload.monthly_price=price.value===''?null:Number(price.value);await api(`/resources/${kind}/${row.id}/coverage`,payload);$('message').className='alert alert-success';$('message').textContent='Dækningen er gemt med historik.';await load();});card.append(save);$('list').append(card);}}
let requestBusy=false;async function run(fn){if(requestBusy)return;requestBusy=true;document.querySelector('.workflow-page').setAttribute('aria-busy','true');try{await fn();}catch(e){$('message').className='alert alert-danger';$('message').textContent=e.message;}finally{requestBusy=false;document.querySelector('.workflow-page').setAttribute('aria-busy','false');}}
$('customer').onchange=()=>run(load);run(async()=>{customers=await api('/customers');tags=await api('/tags');const map=new Map(tags.map(t=>[t.id,t]));function path(t,seen=new Set()){if(seen.has(t.id))return t.name;seen.add(t.id);const p=map.get(t.parent_id);return p?path(p,seen)+' › '+t.name:t.name;}tags=tags.map(t=>({...t,label:path(t)}));customers.forEach(c=>{const o=el('option',c.name);o.value=c.id;$('customer').append(o);});await load();});
})();</script>{% endblock %}

View File

@ -0,0 +1,11 @@
"""Page controls follow the same effective permissions as the API guards."""
def page_permissions(user):
permissions = set(user.get('permissions') or [])
admin = bool(user.get('is_superadmin'))
result = {key: admin or 'workflows.' + key in permissions
for key in ('view', 'manage', 'activate', 'override', 'settle')}
result['decisions_view'] = admin or 'workflows.decisions.view' in permissions
result['system_admin'] = admin or 'system.admin' in permissions
result['economic_export'] = admin or 'economic.documents.export' in permissions
return result

View File

@ -2,21 +2,24 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.workflows.router import view_access
from app.core.auth_dependencies import require_all_permissions
from app.workflows.ui_access import page_permissions
router = APIRouter()
templates = Jinja2Templates(directory='app')
resource_access = require_all_permissions('workflows.view', 'workflows.manage')
@router.get('/workflows', response_class=HTMLResponse)
def workflow_page(request: Request, user=Depends(view_access)):
return templates.TemplateResponse('workflows/frontend/builder.html', {'request': request})
return templates.TemplateResponse('workflows/frontend/builder.html', {'request': request, 'workflow_permissions': page_permissions(user)})
@router.get('/workflows/resources', response_class=HTMLResponse)
def resource_page(request: Request, user=Depends(view_access)):
return templates.TemplateResponse('workflows/frontend/resources.html', {'request': request})
def resource_page(request: Request, user=Depends(resource_access)):
return templates.TemplateResponse('workflows/frontend/resources.html', {'request': request, 'workflow_permissions': page_permissions(user)})
@router.get('/workflows/operations', response_class=HTMLResponse)
def operations_page(request: Request, user=Depends(view_access)):
return templates.TemplateResponse('workflows/frontend/operations.html', {'request': request})
return templates.TemplateResponse('workflows/frontend/operations.html', {'request': request, 'workflow_permissions': page_permissions(user)})

View File

@ -0,0 +1,347 @@
# Manuel testplan – workflows, tags og afregning
Dato: 8. oktober 2026. Grundlag: lokal implementering `b398278` og [implementeringsstatus](workflow-status.md).
Planen er til manuel accepttest; nedenstående resultater er forventninger, ikke allerede udførte manuelle tests. Kør i rækkefølge. Sæt hver test til **Bestået / Fejlet / Ikke testet**, og notér ID'er og observationer i skemaet nederst.
## Nye sider
Links bruger standardporten 8001. Log ind på den lokale Hub først.
De nye administrationssider findes i hovedmenuen **Automatisering**. Menupunkterne følger brugergruppernes rettigheder: Workflows og Workflowdrift kræver `workflows.view`; Aftaledækning kræver både `workflows.view` og `workflows.manage`; Tagklassifikation kræver `system.admin`. Personlige menuindstillinger kan desuden skjule et tilladt punkt. Hvorfor åbnes fortsat fra den konkrete tid.
| Side | Lokalt link | Indhold |
|---|---|---|
| Workflow Builder | [Åbn workflows](http://localhost:8001/workflows) | Regler, versioner, simulator, aktivering, kopiering og deaktivering |
| Aftaler og klippekort | [Åbn aftaledækning](http://localhost:8001/workflows/resources) | Inkluderede/udelukkede tags, undertags og månedspris |
| Workflowdrift | [Åbn drift og eksport](http://localhost:8001/workflows/operations) | Faktisk motortilstand, kø, fejlede hændelser, genforsøg og eksportafstemning |
| Afregningsbeslutning | `http://localhost:8001/workflows/time/TIDS_ID` | Hvorfor, beslutningshistorik, manuel overstyring og genberegning. Erstat TIDS_ID med en faktisk tidsregistrerings ID; siden åbnes også via Hvorfor i økonomikøen |
| Taghierarki og klassifikation | [Åbn klassifikation](http://localhost:8001/tags/classification) | Overtagsstruktur, nøgleordsregler, AI-forslag, godkendelse og taghistorik |
Eksisterende sider, som er udvidet og også skal testes:
- [Økonomikø](http://localhost:8001/economy/time-queue): workflowmetode, Hvorfor, godkendelse og valg mellem separate/samlede fakturalinjer.
- [Tags](http://localhost:8001/tags): oprettelse af tags og link til klassifikation.
- [Fastprisrapporter](http://localhost:8001/fixed-price-agreements/reports/dashboard): pris-/kostprisestimater og det eksisterende forbrugsgrundlag.
- Sagsvisning, tidscomposer og bundbar: eksisterende sider/komponenter, hvor medarbejderens afregningsvalg skjules for aktive workflowkunder.
## 1. Miljø og startpunkt
Brug kun den lokale udviklingsinstallation, normalt `http://localhost:8001`. Hvis lokalporten er ændret, brug den faktiske lokale port. Brug ikke produktionsadressen. Migrationerne 1062–1066 er installeret lokalt; motoren er som udgangspunkt slukket.
- Brug en særskilt testkunde, fx `WF TEST – må ikke faktureres`, og nye testsager med præfikset `WF TEST`. Brug **ikke kunde 24**, medmindre den faktisk er en isoleret testkunde.
- En gendannet testdatabase kan indeholde rigtige kunder og integrationer. Kontrollér, at øvrige lokale schedulerjobs og integrationer ikke sender til produktion. Workflowflagene styrer kun den nye motor.
- Brug en administrator til opsætning, en almindelig medarbejder til tidsregistrering og en bogholder med workflowrettigheder til godkendelse. Administratoren kan gennemføre første runde alene; gentag rettighedstesten med separate brugere.
- Workflowrettigheder: `workflows.view`, `workflows.manage`, `workflows.activate`, `workflows.decisions.view`, `workflows.override`, `workflows.settle`. Tagadministration kræver `system.admin`. Roller får ikke automatisk de nye rettigheder.
- Tag et nyt lokalt checkpoint før aktive tests. Kommandoerne nedenfor ændrer ikke konfiguration automatisk.
I **zsh**, fra `/Users/christianthomas/DEV/bmc_hub_dev`, vis først backuphandlingen:
```zsh
./.venv/bin/python scripts/workflow_backup.py backup
```
Opret derefter backuppen:
```zsh
./.venv/bin/python scripts/workflow_backup.py backup --apply --confirm BACKUP-LOCAL-BMC
```
Notér den udskrevne mappe. `/private/tmp` er midlertidig opbevaring; bevar en privat kopi varigt. [Tilbageførsel](workflow-rollback.md) beskriver de forskellige muligheder.
### Skift testtilstand
Ret kun de nedenstående nøgler i den **lokale `.env`**; bevar resten af filen og eventuelle eksisterende værdier til senere gendannelse. Kundelisten er JSON med testkundens **Hub-kunde-ID**, ikke et vTiger-ID.
| Nøgle | Slukket | Skyggetilstand | Aktiv test |
|---|---|---|---|
| `WORKFLOWS_ENABLED` | `false` | `true` | `true` |
| `WORKFLOWS_MODE` | `shadow` | `shadow` | `active` |
| `WORKFLOWS_CUSTOMER_IDS` | `[]` | `[TESTKUNDE_ID]` | `[TESTKUNDE_ID]` |
| `WORKFLOWS_INTERNAL_ENABLED` | `false` | `false` | `false` |
| `WORKFLOW_AI_ENABLED` | `false` | `false` | `false` |
Erstat `TESTKUNDE_ID` med et tal, fx `[123]`. En aktiveret workflowversion og en aktiveret motor er to forskellige ting; begge skal være sat korrekt.
Efter ændringer til `.env` skal Docker-servicen genskabes, fordi `env_file` indlæses ved oprettelse. En almindelig restart er ikke tilstrækkelig til at erstatte containerens gamle miljøvariabler.
Kontrollér først Compose-filen i zsh:
```zsh
docker compose -f docker-compose.yml config --quiet
```
Hvis den består, genopret kun den lokale API-service:
```zsh
docker compose -f docker-compose.yml up -d --no-deps --force-recreate api
```
Kontrollér derefter den faktiske tilstand på `/workflows/operations`. Fortsæt kun, når banneret viser den forventede tilstand og kundeliste. Ved brug af lokal uvicorn i stedet for Docker: stop og start den samme lokale proces med den opdaterede konfiguration.
## 2. Testdata
Opret via de eksisterende kunde-, aftale-, kort-, tag- og sagsskærme. Brug samme testkunde til alle finansielle tests og en anden testkunde uden for kundelisten til kontrol.
| Data | Opsætning / ID, du skal notere |
|---|---|
| Kunde A | Workflowtestkunde, Hub-ID: ______ |
| Kunde B | Kontrolkunde uden for kundelisten, Hub-ID: ______ |
| Tags | `WF TEST Microsoft` → `WF TEST Office` → `WF TEST Excel`; desuden `WF TEST Mobiltelefoni` og `WF TEST IT-support` |
| Excel-nøgleord | Hele ord: `Excel`; alternativer `regneark`; særskilt Indeholder-regel: `.xlsx`; negativt ord `uden Excel`; prioritet 10 |
| Fastprisaftale A | Aktiv for kunde A, gældende i testperioden; fx 20 timer og intern kostpris 300 kr./time; notér ID |
| Klippekort A | Aktivt for kunde A, 2 timer til rådighed, afrunding 30 minutter; notér ID og startsaldo |
| Sag A1 | Kunde A, titel `WF TEST Excel`, manuel tagtildeling Excel |
| Sag A2 | Kunde A, titel `WF TEST Mobil`, tag Mobiltelefoni |
| Sag A3 | Kunde A, titel `WF TEST Kort`, tag IT-support |
| Sag B1 | Kunde B, titel `WF TEST Kontrol` |
| Fakturavare | Et eksisterende aktivt Hub-varenummer med korrekt e-conomic-tilknytning; notér nummeret. Ellers test lokal standardvare og spring specifik varetest over |
Tags oprettes på `/tags`; overtag og nøgleord vedligeholdes på `/tags/classification`. Markér de tags, som bruges til afregning, med **Kan påvirke afregning**. Brug testtags i en gruppe, der tillader flere tags, så eksisterende enkeltvalgsregler ikke forstyrrer testen.
På `/workflows/resources`: sæt aftale A til at dække Microsoft inklusive undertags, udelukke Mobiltelefoni og have månedspris 15.000 kr. Sæt kort A til kun at dække IT-support. Gem og genåbn for at kontrollere værdierne. Ændr kun testressourcer.
### Testworkflows
Opret på `/workflows`. Felter og handlinger har danske navne: `tag_ids` vises som Tags, `contains` som Indeholder og `hours` som Timer. Sæt **Kunde** til kunde A. Brug Tags + Indeholder til tagbetingelser.
| Navn | Prioritet | IF | Handling | Stop efter match |
|---|---:|---|---|---|
| WF TEST Mobil | 10 | Tag indeholder Mobiltelefoni | Vælg afregning → Faktura, evt. testvarenummer | Ja |
| WF TEST Kort | 20 | Tag indeholder IT-support | Vælg afregning → Klippekort, vælg kort A | Ja |
| WF TEST Office | 30 | Tag indeholder Microsoft, Medtag undertags = ja | Vælg afregning → Fastpris, vælg aftale A | Ja |
| WF TEST Standard | 100 | `hours` `gt` `0` | Send til manuel vurdering, begrundelse `Ingen testregel dækkede arbejdet` | Ja |
Trigger skal være **Tid oprettet** (`time.created`). Gem, test den gemte version med en relevant sag, og aktivér den testede version. Lav derefter kopier med trigger **Tid ændret** (`time.updated`), gem, test og aktivér dem. Oprettelsesregler matcher ikke automatisk ændringshændelser; begge sæt er nødvendige til genberegningstests.
Aktivér kun testreglerne. Eksisterende aktive regler kan påvirke resultatet; notér dem og gennemgå deres scope før testen. Simulatoren tester den valgte definition, mens køen evaluerer alle relevante aktive workflows.
## 3. Slukket motor og simulator
### T01 – Eksisterende arbejdsgang
1. Sæt slukket tilstand og kontrollér banneret.
2. Registrér almindelig tid på sag B1 gennem den normale medarbejderskærm.
3. Åbn økonomikøen `/economy/time-queue` og kontrollér den eksisterende afregningsarbejdsgang.
**Forventet:** ingen ny automatisk workflowbeslutning og ingen ændring af de hidtidige valg. Sammenlign med arbejdsgangen fra før ændringen. Undlad afregning af kontroltiden.
### T02 – Hierarki, søgning og cyklus
1. Find Excel på `/tags/classification`. Kontrollér stien Microsoft / Office / Excel.
2. Søg på `Excel`, derefter `Microsoft`. Kontrollér også tagvælgeren på en testsag.
3. Forsøg at sætte Microsofts overtag til Excel.
**Forventet:** søgning og tagvalg virker; cyklusforsøget afvises og det oprindelige hierarki bevares efter genindlæsning.
### T03 – Simulator og undertags
1. Åbn Office-workflowet og vælg **Eksisterende data → Sag**, ID A1.
2. Klik **Test gemt version**. Kontrollér Fastpris og match på Microsoft med undertags.
3. Notér kortsaldo, aftaleforbrug og antal testordrekladder. Gentag testen tre gange.
4. Gem en ny version med Medtag undertags = nej, og test samme sag.
**Forventet:** den første version matcher Excel gennem Microsoft. Den anden matcher ikke Microsoft, når kun Excel er tildelt. Simulatoren ændrer hverken tags, saldo, forbrug eller kladder. Gem en tredje version med undertags aktiveret igen til de følgende tests.
### T04 – Versioner, kopiering og tilbagevalg
1. Kontrollér at **Aktivér testet version** er deaktiveret før test.
2. Test, og ændr derefter en betingelse uden at gemme. Aktivér må igen være deaktiveret.
3. Gem en ny version; kontrollér at den gamle version stadig kan vælges og indeholder den gamle regel.
4. Kopiér workflowet; kopien må være inaktiv. Deaktivér kopien.
5. Vælg en tidligere version, test og aktivér den. Kontrollér aktiveringshistorikken.
**Forventet:** aktivering kræver test af den gemte version; gamle versioner bevares. Gendan og aktivér de tilsigtede testversioner før næste fase.
### T05 – AND, OR og ELSE IF
1. Lav et særskilt inaktivt simulatorworkflow: kunde A **AND** (beskrivelse indeholder `Excel` **OR** `regneark`) → Faktura.
2. Tilføj ELSE IF beskrivelse indeholder `intern test` → Ikke fakturerbar; ELSE → manuel vurdering.
3. Test indtastede data: kunde A + `Hjælp med Excel`; kunde A + `Hjælp med regneark`; kunde B + `Excel`; kunde A + `intern test`.
**Forventet:** henholdsvis Faktura, Faktura, manuel vurdering og Ikke fakturerbar. Sporet viser både match og ikke-match. Hold dette workflow inaktivt.
## 4. Skyggetilstand
### T06 – Analyse uden forretningsændringer
1. Sæt skyggetilstand; lad AI være slukket. Kontrollér banneret.
2. Registrér 45 minutter på A1 med beskrivelse `Hjulpet med Excel` og notér tids-ID.
3. Vent op til 60 sekunder, og genindlæs økonomikøen. Jobbet kører normalt hvert 15. sekund; større køer kan tage længere.
4. Åbn **Hvorfor?**, eller `/workflows/time/TIDS_ID`.
5. Sammenlign kortsaldo, aftaleforbrug, kladder og konkrete tags med før registreringen.
**Forventet:** skyggeresultat foreslår Fastpris og viser spor/historik. Motoren har ikke ændret afregning, trukket timer eller tilføjet automatiske tags. Manuel overstyring/afregning gennem workflow kræver aktiv tilstand. Eksisterende automatisering kan stadig reagere; undersøg dens kilde ved ændringer.
## 5. Aktiv test på kunde A
Skift til aktiv test. Opret **nye** tidsregistreringer; tidligere hændelser, der allerede er behandlet i skyggetilstand, skal ikke forventes automatisk genafspillet.
### T07 – Medarbejderen vælger kun tid og beskrivelse
1. Som medarbejder: registrér 45 minutter på A1, `Hjulpet med Excel`.
2. Kontrollér sagsskærm, tidscomposer og bundbar: afregningsvalg/direkte godkendelse må ikke være tilgængelige for denne aktive workflowkunde.
3. Registrér tid på B1 som kontrol.
**Forventet:** A-tiden går videre til bogholderiet uden medarbejdervalg af afregning. B følger eksisterende arbejdsgang. Ingen korttimer trækkes ved registrering.
### T08 – Fastpris, faktura, kort og manglende regel
Opret én ny tid på hver sag: A1 45 min, A2 30 min og A3 15 min. Opret også en ny sag for A uden testtags og registrér 20 min med neutral beskrivelse.
**Forventet i Hvorfor:** A1 Fastpris med aftale A; A2 Faktura; A3 Klippekort med kort A; neutral tid manuel vurdering. Bogholderiet kan se de anvendte workflows og deres spor. Saldo og forbrug er uændrede før godkendelse.
### T09 – Nøgleord, negative ord og manuelle tags
1. Brug nye A-sager uden Excel-tag. Registrér `Excel`, `Hjælp med regneark`, `Åbnede filen budget.xlsx`, `Excelkursus` og `Arbejdet blev udført uden Excel` på forskellige tider.
2. Se klassifikationshistorik for **Tidsregistrering** og tids-ID på `/tags/classification`. Se også Hvorfor → Tekniske detaljer for konkrete tag-ID'er.
3. Brug ikke en sag med Excel i titel/beskrivelse til de negative prøver; klassifikationen bruger også sagens indhold.
**Forventet:** de første tre giver Excel-tag via nøgleordsregler. `Excelkursus` matcher ikke Hele ord-reglen; den negative formulering matcher ikke den pågældende regel. Andre regler kan stadig matche, så gennemgå alle regelkilder. Et manuelt tildelt tag skal bevares ved senere behandling; historie skal vise kilden.
### T10 – Konflikt og prioritet
1. Kopiér Mobil-workflowet med samme kunde, trigger og prioritet 10, men vælg Ikke fakturerbar. Test og aktivér kopien.
2. Registrér ny tid på A2.
3. Kontrollér manuel vurdering og konfliktspor; der må ikke oprettes afregning automatisk.
4. Deaktivér konfliktkopien og opret en ny A2-tid.
**Forventet:** to modstridende afregningsvalg i samme prioritetsgruppe giver manuel vurdering, også med Stop efter match. Uden kopien vælges Faktura. En senere regel med højere prioritetsnummer må ikke tilsidesætte et stoppet match.
### T11 – Manuel overstyring og genberegning
1. Vælg en endnu ikke afregnet A2-tid. På Hvorfor: prøv overstyring uden begrundelse.
2. Vælg Ikke fakturerbar, skriv `Manuel testaftale`, og gem overstyring.
3. Klik **Genberegn (bevar overstyring)**. Redigér også tidsbeskrivelsen og vent på ny behandling.
4. Kontrollér metode og historik. Som administrator: skriv en begrundelse og klik **Ophæv overstyring og genberegn**.
**Forventet:** tom begrundelse afvises; historik bevarer oprindelig beslutning og overstyring. Normal genberegning bevarer overstyringen; eksplicit ophævelse anvender de aktive `time.updated`-regler. Ingen afregning udføres af disse knapper.
### T12 – Bogholderiets preview og godkendelse
1. Åbn `/economy/time-queue`, filtrér kunde A og derefter hver faktureringstype; kontrollér også filter for afsluttede sager.
2. Vælg kun nye, ubehandlede A-tider med entydige beslutninger. Kontrollér at de gamle fælles metodevalg er deaktiverede.
3. Åbn preview. Notér saldo og forbrug før og efter preview.
4. Klik **Gennemfør afregning** én gang. Kontrollér tider og de relevante ressourcer.
**Forventet:** preview ændrer intet. Faktura opretter lokal ordrekladde; Fastpris øger faktisk forbrug med 0,75 time ved A1-prøven; kortet trækker 0,5 time for A3's 15 minutter ved 30-minutters afrunding. Intern/Ikke fakturerbar opretter ingen fakturakladde. Intet sendes til e-conomic fra denne godkendelse.
### T13 – Separate eller samlede fakturalinjer
1. Registrér to nye A2-tider på samme sag, 30 og 45 minutter, samme vare og sats.
2. Vælg begge, vælg separate linjer, og se preview: forvent to linjer med tilsammen 1,25 time. Gennemfør og notér kladde-ID.
3. Opret et nyt tilsvarende par, vælg sammenlagte linjer og gennemfør.
**Forventet:** andet par samles til én linje med 1,25 time, og alle tids-ID'er kan spores. Forskellige varenumre må ikke samles som samme vare. Brug nye tider til hver prøve; allerede afregnede tider kan ikke bruges igen.
### T14 – Saldo, dækning og datoer
1. Notér kortets aktuelle saldo. Opret korttid, der kræver flere afrundede timer end saldoen.
2. Prøv en Fastpris-overstyring på Mobil-tid, selvom Mobil er udelukket af aftalen.
3. Brug en separat udløbet/inaktiv testaftale eller et udløbet testkort i en regel eller overstyring.
4. Prøv en manglende/ukendt fakturavare på en ny fakturatid, og godkend gennem køen.
**Forventet:** manuel vurdering eller tydelig afvisning; ingen negativ saldo, ugyldigt aftaleforbrug eller halv oprettet kladde. En manglende vare kan først afvises ved afregning. En manuel overstyring må ikke omgå ressourcevalidering. Notér det faktiske tidspunkt og den viste fejl.
### T15 – Gentagen og samtidig godkendelse
1. Opret en ny A3-tid med tilstrækkelig saldo. Åbn samme valg i to browserfaner og lav preview i begge.
2. Gennemfør i den første fane, derefter i den anden. Prøv også dobbeltklik ved en separat ny test.
3. Kontrollér kortets transaktionshistorik og saldo, ikke kun toastbeskeden.
**Forventet:** højst ét korttræk pr. tids-ID. Andet forsøg er allerede behandlet eller afvises. Gentag med Faktura: højst én kladde pr. tids-ID. Hvorfor kan stadig dokumentere den afregnede beslutning, og ændring/genberegning af den skal afvises.
### T16 – Fastprisrapport og rettigheder
1. Åbn eksisterende fastprisrapport `/fixed-price-agreements/reports/dashboard`; kontrollér testaftalens månedspris, budget og forbrug efter godkendelse.
2. Kontrollér rentabilitet for den relevante periode: månedens pris og intern kostpris indgår, og brugte timer er de godkendte testtimer. Skeln mellem dashboardets estimat og faktisk perioderapport.
3. Log ind som medarbejder uden administrationsrettigheder. Forsøg at åbne workflow-/tagadministration, overstyre og godkende.
4. Log ind som bogholder med de tilsigtede rettigheder og kontrollér tilladte handlinger.
5. Kontrollér Automatisering-menuen: medarbejderen uden rettigheder ser ingen nye punkter; læsebrugeren ser Workflows/Workflowdrift, men ingen redigeringsknapper eller Aftaledækning/Tagklassifikation. En systemadministrator med kun `system.admin` ser Tagklassifikation. Kontrollér også direkte URL'er.
6. Skjul et tilladt menupunkt i Profil → menuindstillinger, genindlæs og kontrollér, at det forbliver skjult. Vis det igen. Et punkt uden adgang må ikke kunne gøres synligt gennem personlige menuvalg.
7. Kontrollér de fem sider i lyst/mørkt tema og ved smal vinduesbredde: tekst, felter, knapper og tagstier skal kunne læses uden afskårne handlinger.
**Forventet:** ingen nye skriverettigheder for medarbejderen. En synlig knap er ikke i sig selv bevis på adgang; handlingen skal afvises af serveren, hvis rettigheden mangler.
### T17 – Øvrige handlinger og stop af loops
1. Opret særskilt testworkflow med `case.created` for kunde A, titlen `WF TEST Handling`, og handlinger Opret opgave, Sæt felt → priority med en gyldig eksisterende prioritet, og Notifikation til testbrugeren.
2. Test, aktivér og opret en ny testsag. Kontrollér én opgave, den valgte prioritet og frontendnotifikation; ingen workflowmail/Mattermost forventes.
3. Test Opret reminder med fremtidigt tidspunkt og testmodtager på en ny testsag.
4. Valgfrit: opret to testworkflows, som starter hinanden. Kontrollér at kæden stopper; følg køen på Drift og eksport. Deaktivér dem straks efter prøven.
**Forventet:** handlinger knyttes til den rigtige sag. Gentagen behandling skaber ikke samme handling igen for samme event. Loop afvises eller stoppes af kædegrænsen; det må ikke fortsætte uden begrænsning. Nye, forskellige events kan legitimt udføre en regel igen.
## 6. Valgfrie integrationstests
### T18 – AI-forslag
Kun hvis lokal Ollama-model og endpoint fungerer. Notér eksisterende værdier; ret ingen produktionsendpoint. Sæt `WORKFLOW_AI_ENABLED=true`, behold aktiv testkunde og sæt tærsklerne til `WORKFLOW_AI_AUTO_THRESHOLD=90`, `WORKFLOW_AI_SUGGEST_THRESHOLD=70`. Genopret API og kontrollér banneret.
1. Lav en ny sag med neutral tekst og en ny tid: `Formlerne beregner forkert i regnearket`. Undgå konfigurerede nøgleord, hvis du vil isolere AI-delen; justér testteksten til fx `Cellerne viser forkerte beregnede tal`.
2. Vent på AI-jobbet. På `/tags/classification`: vælg Tidsregistrering, angiv tids-ID og klik Vis.
3. Kontrollér forslagets tag, model, forklaring og confidence i historikken. Et forslag er ikke garanteret, da modellen er variabel.
4. Hvis Excel foreslås og påvirker afregning: prøv bogholderigodkendelse før review; godkend eller afvis derefter forslaget med begrundelse.
5. Genindlæs Hvorfor efter ny behandling. Test også en ændret beskrivelse, mens et job endnu afventer.
**Forventet:** afregningsrelevante AI-tags kræver review selv ved høj confidence og blokerer afregning, mens relevante forslag/job er uafklarede. Gamle resultater må ikke anvendes på ændret tekst. AI-confidence er modellens vurdering. Hvis modellen ikke foreslår et relevant tag, markér reviewprøven Ikke testet; det er ikke bevis på fungerende review. Ved utilgængelig AI: undersøg jobstatus/teknisk log og markér integrationen fejlet eller blokeret.
Slå AI fra igen efter testen. Automatisk AI-kø er implementeret for tidsregistrering; antag ikke, at oprettelse af en vilkårlig mail alene starter denne AI-kø.
### T19 – e-conomic
Spring denne prøve over, hvis der ikke er en verificeret særskilt e-conomic-testaftale med testkunde og testvare. En lokal Hub er ikke garanti for, at e-conomic-forbindelsen er test. Denne prøve opretter en ekstern ordre via den eksisterende ordreeksport.
1. Vælg én workflowoprettet fakturakladde. Kontrollér kunde, vare, timer og beløb samt eksportbrugerens eksisterende rettighed `economic.documents.export`.
2. Overfør gennem den eksisterende ordreskærm. Notér ekstern ordre og eksportreference på `/workflows/operations`.
3. Forsøg samme eksport igen; kontrollér i e-conomic, at der ikke er en ekstra ordre.
4. Ved reelt uafklaret svar: brug **Kontrollér og afstem eksisterende ordre** med den eksisterende ordres nummer. Prøv ikke blind geneksport.
**Forventet:** én ekstern ordre, stabil reference og bevaret tids-/linjespor. Afstemning kontrollerer kunde, varer og beløb og opretter ikke en ny ordre. En forkert ordres nummer afvises. Fremprovokér ikke en timeout ved at slukke produktion/netværk; fejl-/timeoutprøven kan markeres Ikke testet, hvis et kontrolleret testsetup mangler.
## 7. Sluk igen og kontrollér tilbagevejen
### T20 – Deaktivering uden tab af data
1. Notér sidste afregning, kortsaldo, forbrug og kladde-ID'er.
2. Sæt `WORKFLOWS_ENABLED=false`, `WORKFLOW_AI_ENABLED=false`, `WORKFLOWS_INTERNAL_ENABLED=false`; genopret API.
3. Kontrollér Slukket på `/workflows/operations`.
4. Opret en ny kontroltid og kontrollér den gamle arbejdsgang.
5. Kontrollér at tidligere beslutninger og gennemførte afregninger stadig kan dokumenteres.
**Forventet:** ny workflowbehandling ophører. Slukning tilbagefører ikke gennemførte korttræk eller sletter kladder; historik bevares. Deaktivér testworkflows, og arkivér testdata gennem eksisterende funktioner. Ret gennemførte testafregninger gennem den normale økonomiproces.
Permanent afinstallation og fuld databasegendannelse indgår **ikke** i den almindelige manuelle test. Afprøv kun dette i en separat disposable database efter [rollbackvejledningen](workflow-rollback.md). En fuld restore fjerner også senere data.
## 8. Fejlrapport og accept
Hvis der er uventet korttræk, dobbelt kladde, forkert kunde, manglende historik eller ændringer i skyggetilstand: stop den aktive test, slå motor/AI fra og bevar data til fejlsøgning. Fortsæt ikke med flere afregninger.
Notér ved fejl: testnummer, lokal URL, tidspunkt, brugerrolle, kunde-/sag-/tids-ID, workflow og version, tilstand, forventet/faktisk resultat og eventuel fejltekst. Gem gerne et skærmbillede og Hvorfor-detaljer; del ingen tokens, passwords eller `.env`-indhold.
| Test | Bestået / Fejlet / Ikke testet | ID'er og observationer |
|---|---|---|
| T01 Eksisterende adfærd | | |
| T02 Hierarki | | |
| T03 Simulator/undertags | | |
| T04 Versioner | | |
| T05 AND/OR/ELSE IF | | |
| T06 Skyggetilstand | | |
| T07 Medarbejderflow | | |
| T08 Afregningsvalg | | |
| T09 Nøgleord | | |
| T10 Konflikter | | |
| T11 Overstyring | | |
| T12 Godkendelse | | |
| T13 Fakturalinjer | | |
| T14 Ressourcekontrol | | |
| T15 Dobbeltbehandling | | |
| T16 Rapporter/rettigheder | | |
| T17 Handlinger/loops | | |
| T18 AI (valgfri) | | |
| T19 e-conomic (valgfri) | | |
| T20 Deaktivering | | |
Grundforløbet er manuelt accepteret, når T01–T17 og T20 består, og ingen alvorlige fejl står åbne. AI og e-conomic kan kun accepteres særskilt efter deres egne tests. Slukket standard er ikke i sig selv godkendelse til produktion.
Automatisk SLA/eskalering, aftaleudløb samt eventkilder til `reminder.due` og `notification.created` er endnu ikke tilkoblet og kan ikke accepteres med denne plan. Registrér dem som **Ikke implementeret**, ikke Bestået. Sagoprettelse via workflow er heller ikke implementeret. Se [status](workflow-status.md).

View File

@ -33,3 +33,11 @@ Migrationerne 1062–1066 er installeret i den lokale `bmc_hub` i én transaktio
149 fokuserede Python-tests består; de 15 databasecases, som normalt springes over, er afprøvet særskilt mod `bmc_workflow_test_20261008`. De dækker samtidighed, rollback, gentagen behandling, manuel overstyring, eksport og afinstallation. 21 eksisterende JavaScript-tests består. Skabeloner, JavaScript og Python er kontrolleret; visuel browserkontrol var ikke tilgængelig.
Den oprindelige databasebackup er gendannet i testdatabasen. Baselinebackup ligger i `/private/tmp/bmc-workflow-backup-20261008`; et ekstra checkpoint med kildekode og manifest ligger i `/private/tmp/bmc-workflow-backup-6i_a037k`. Disse mapper er private, men `/private/tmp` er ikke permanent backupopbevaring.
## Sider og menu – 8. oktober 2026
De fem nye sider bruger Hub’ens fælles layout, kort og temafarver med kompakte, responsive felter og tydelige tomme/indlæsnings-/fejltilstande. Hovedmenuen Automatisering følger de eksisterende effektive grupperettigheder og personlige menuvalg. Der er ikke tildelt nye rettigheder til nogen grupper.
Workflows/Workflowdrift kræver `workflows.view`; Aftaledækning kræver `workflows.view` og `workflows.manage`; Tagklassifikation kræver `system.admin`. Hvorfor kræver `workflows.decisions.view` og åbnes via tidsregistreringen. Skriveknapper følger de tilsvarende API-rettigheder. Klassifikations- og ressourceeditoren har også adgangskontrol ved direkte sideåbning.
Render-, syntaks- og rettighedstests er udført lokalt. Visuel kontrol i en browser er ikke udført i denne session. Se [manuel testplan](workflow-manuel-testplan.md) for menu-, rolle- og temakontrol.

20
static/js/menu-access.js Normal file
View File

@ -0,0 +1,20 @@
/* Effective group permissions supplement personal menu preferences. */
(function (root) {
'use strict';
function allowed(user, required) {
if (!user) return false;
if (user.is_superadmin === true) return true;
const permissions = new Set(Array.isArray(user.permissions) ? user.permissions : []);
return required.split('|').some(group => {
const codes = group.split(',').map(value => value.trim()).filter(Boolean);
return codes.length > 0 && codes.every(permission => permissions.has(permission));
});
}
function apply(user, document) {
document.querySelectorAll('[data-menu-permission]').forEach(node => {
node.toggleAttribute('data-access-denied', !allowed(user, node.dataset.menuPermission));
});
}
root.BmcMenuAccess = {allowed, apply};
if (typeof module !== 'undefined' && module.exports) module.exports = root.BmcMenuAccess;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@ -0,0 +1,27 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {allowed, apply} = require('../static/js/menu-access.js');
test('group permissions show only accessible workflow sections', () => {
const reader = {permissions: ['workflows.view']};
assert.equal(allowed(reader, 'workflows.view'), true);
assert.equal(allowed(reader, 'workflows.view,workflows.manage'), false);
assert.equal(allowed(reader, 'system.admin'), false);
assert.equal(allowed(reader, 'workflows.view|system.admin'), true);
assert.equal(allowed({permissions: ['system.admin']}, 'workflows.view|system.admin'), true);
assert.equal(allowed({is_superadmin: true}, 'workflows.manage'), true);
assert.equal(allowed(null, 'workflows.view'), false);
});
test('permission updates do not overwrite personal menu preferences', () => {
const node = {dataset: {menuPermission: 'workflows.view'}, hidden: true,
attributes: new Set(['data-access-denied']),
toggleAttribute(name, enabled) { enabled ? this.attributes.add(name) : this.attributes.delete(name); }};
const document = {querySelectorAll: () => [node]};
apply({permissions: ['workflows.view']}, document);
assert.equal(node.attributes.has('data-access-denied'), false);
assert.equal(node.hidden, true);
apply({permissions: []}, document);
assert.equal(node.attributes.has('data-access-denied'), true);
assert.equal(node.hidden, true);
});

View File

@ -0,0 +1,91 @@
"""Render real workflow templates to verify permissions and common page structure."""
from html.parser import HTMLParser
from types import SimpleNamespace
from jinja2 import ChoiceLoader, DictLoader, Environment, FileSystemLoader
class Elements(HTMLParser):
def __init__(self, html):
super().__init__()
self.ids = {}
self.links = []
self.fieldsets = []
self.fieldset_stack = []
self.disabled_ancestors = {}
self.feed(html)
def handle_starttag(self, tag, attributes):
attrs = dict(attributes)
if 'id' in attrs:
self.ids[attrs['id']] = attrs
self.disabled_ancestors[attrs['id']] = any('disabled' in parent for parent in self.fieldset_stack)
if tag == 'a':
self.links.append(attrs.get('href'))
if tag == 'fieldset':
self.fieldsets.append(attrs)
self.fieldset_stack.append(attrs)
def handle_endtag(self, tag):
if tag == 'fieldset' and self.fieldset_stack:
self.fieldset_stack.pop()
def render(name, permissions=None):
env = Environment(loader=ChoiceLoader([
DictLoader({'shared/frontend/base.html': '{% block extra_css %}{% endblock %}{% block content %}{% endblock %}{% block extra_js %}{% endblock %}'}),
FileSystemLoader('app'),
]), autoescape=True)
return env.get_template('workflows/frontend/'+name+'.html').render(
workflow_permissions=permissions or {}, time_id=12,
request=SimpleNamespace(url=SimpleNamespace(path='/workflows')))
def test_viewer_can_browse_without_write_controls_or_admin_links():
html = render('builder', {'view': True})
page = Elements(html)
assert 'disabled' in page.fieldsets[0]
for key in ('new', 'save', 'copy', 'disable', 'test', 'activate'):
assert 'hidden' in page.ids['wf-'+key]
assert '/workflows/operations' in page.links
assert '/workflows/resources' not in page.links
assert '/tags/classification' not in page.links
assert 'Henter workflows' in html
def test_manage_and_activation_are_separate_from_system_admin_navigation():
html = render('builder', {'view': True, 'manage': True, 'activate': True})
page = Elements(html)
assert 'disabled' not in page.fieldsets[0]
assert 'hidden' not in page.ids['wf-save']
assert 'hidden' not in page.ids['wf-activate']
assert '/workflows/resources' in page.links
assert '/tags/classification' not in page.links
assert '/tags/classification' in Elements(render('builder', {'system_admin': True})).links
def test_override_does_not_expose_reset_without_activation_permission():
page = Elements(render('decision', {'override': True}))
assert 'hidden' in page.ids['decision-reset']
assert 'hidden' not in Elements(render('decision', {'override': True, 'activate': True})).ids['decision-reset']
def test_activation_only_user_can_deactivate_without_editing_definition():
page = Elements(render('builder', {'view': True, 'activate': True}))
assert 'hidden' not in page.ids['wf-disable']
assert not page.disabled_ancestors['wf-disable']
assert 'hidden' in page.ids['wf-save']
def test_ops_permission_flags_are_rendered_as_booleans_for_dynamic_buttons():
readonly = render('operations', {'view': True})
assert 'const canManage=false,canExport=false;' in readonly
assert 'const canManage=true,canExport=true;' in render('operations', {'manage': True, 'economic_export': True})
def test_pages_share_theme_tokens_and_responsive_scope():
for name in ('builder', 'decision', 'resources', 'operations'):
html = render(name, {'view': True})
assert 'container-fluid workflow-page' in html
assert 'workflow-heading' in html
assert 'var(--bg-card' in html
assert 'var(--text-secondary' in html
assert '@media(max-width:576px)' in html

View File

@ -0,0 +1,46 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from fastapi.staticfiles import StaticFiles
from app.core.auth_dependencies import get_current_user
from app.core.auth_service import AuthService
from app.workflows import views, decisions
from app.tags.backend import views as tag_views
def client(monkeypatch, permissions=(), admin=False):
app = FastAPI()
app.mount('/static', StaticFiles(directory='static'), name='static')
app.include_router(views.router)
app.include_router(decisions.views)
app.include_router(tag_views.router)
user = {'id': 1, 'username': 'test', 'permissions': list(permissions), 'is_superadmin': admin}
app.dependency_overrides[get_current_user] = lambda: user
monkeypatch.setattr(AuthService, 'user_has_permission', lambda _, code: code in permissions)
return TestClient(app)
@pytest.mark.parametrize('path', ['/workflows', '/workflows/resources', '/workflows/operations', '/workflows/time/1', '/tags/classification'])
def test_pages_reject_users_without_group_access(monkeypatch, path):
assert client(monkeypatch).get(path).status_code == 403
@pytest.mark.parametrize('permissions,path', [
(['workflows.view'], '/workflows'),
(['workflows.view'], '/workflows/operations'),
(['workflows.view', 'workflows.manage'], '/workflows/resources'),
(['workflows.decisions.view'], '/workflows/time/1'),
(['system.admin'], '/tags/classification'),
])
def test_pages_render_for_matching_group_access(monkeypatch, permissions, path):
assert client(monkeypatch, permissions).get(path).status_code == 200
def test_reader_cannot_open_resource_editor(monkeypatch):
assert client(monkeypatch, ['workflows.view']).get('/workflows/resources').status_code == 403
def test_superadmin_can_open_all_pages(monkeypatch):
browser = client(monkeypatch, admin=True)
for path in ['/workflows', '/workflows/resources', '/workflows/operations', '/workflows/time/1', '/tags/classification']:
assert browser.get(path).status_code == 200