bmc_hub/app/workflows/frontend/builder.html
Christian 85e019783b Add comprehensive test coverage for case creation, assignment, and remote recovery
- Introduced tests for case creation commands, ensuring proper handling of UUIDs and payloads.
- Added tests for v5 assignment functionality, validating employee identification and group assignment.
- Implemented tests for remote recovery scenarios, including handling network failures and response validation.
- Created tests for hardware label generation and validation, ensuring proper URL formatting and QR code generation.
- Developed extensive tests for remote control functionality, covering connection isolation, command execution, and user permissions.
- Added tests for workflow timer registration, ensuring correct handling of timer states and ownership validation.
2026-10-15 12:17:09 +02:00

56 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 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"><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="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><label class="d-block mt-2">Sagstitel til manuelle testdata<input id="wf-test-case-title" class="form-control" placeholder="Fx TEST tid"></label><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="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 %}
{% block extra_js %}
<script>
(()=>{'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={customer_id:'Kunde',case_id:'Sags-ID',case_title:'Sagstitel',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;}
function element(tag,className,text){const el=document.createElement(tag);if(className)el.className=className;if(text!==undefined)el.textContent=text;return el;}
function select(values,value,onchange){const el=element('select','form-select form-select-sm');values.forEach(v=>{const opt=element('option','',labels[v]||v);opt.value=v;el.append(opt);});el.value=value;el.onchange=()=>{onchange(el.value);changed();};return el;}
function input(value,onchange){const el=element('input','form-control form-control-sm');el.value=typeof value==='string'?value:JSON.stringify(value??'');el.onchange=()=>{let v=el.value;try{v=JSON.parse(v);}catch{}onchange(v);changed();};return el;}
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',case_title:'Sagstitel',case_id:'Sags-ID'},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+' · Testværdi: '+(node.actual==null?'mangler':Array.isArray(node.actual)?node.actual.join(', '):String(node.actual));}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?'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();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();};
$('add-else').onclick=()=>{if(!definition.branches.some(b=>b.condition===null))definition.branches.push({condition:null,actions:[{type:'set_billing',method:'invoice'}]});changed();render();};
$('save').onclick=()=>guard(async()=>{definition.trigger=$('trigger').value;definition.stop_after_match=$('stop').checked;const draft={name:$('name').value,customer_id:$('customer').value||null,priority:Number($('priority').value),definition};const saved=await api(current?'/'+current.id+'/versions':'',draft);await list();await open(current?current.id:saved.id);message('Ny version gemt. Test den før aktivering.');});
$('versions').onchange=()=>loadVersion(current.versions.find(v=>v.id===Number($('versions').value)));
$('test').onclick=()=>guard(async()=>{if(!current||dirty)throw Error('Gem ændringerne som en ny version før test');const entity=$('entity').value;if(!entity&&$('entity-id').value)throw Error('Du har indtastet et ID. Vælg Sag eller Tidsregistrering under Eksisterende data, så testen bruger de faktiske oplysninger.');const payload={version_id:version.id};if(entity){payload.entity_type=entity;payload.entity_id=Number($('entity-id').value);}else payload.context={customer_id:$('test-customer').value||null,description:$('test-description').value,case_title:$('test-case-title').value,hours:Number($('test-hours').value),tag_ids:[]};const data=await api('/'+current.id+'/simulate',payload);receipt=data.simulation_receipt;$('test-explanation').replaceChildren();const summary=element('p','',data.result.status==='decided'?'Afregning: '+(labels[data.result.method]||data.result.method):data.result.actions?.length?'Reglen matcher · '+data.result.actions.length+' handling(er) planlagt. Testen udfører dem ikke.':'Ingen handling matcher · '+(data.result.reason==='no_matching_rule'?'ingen matchende regel':'manuel vurdering'));$('test-explanation').append(summary);for(const trace of data.result.trace||[]){if(trace.source==='resource_coverage'){$('test-explanation').append(element('div','small',trace.matched?'Aftaler og klippekort: dækning matcher':'Aftaler og klippekort: ingen dækning matcher'));continue;}if(!Number.isInteger(trace.branch))continue;$('test-explanation').append(element('div','small fw-semibold',`Workflow #${trace.workflow_id} · gren ${trace.branch+1}: ${trace.matched?'opfyldt':'ikke opfyldt'}`));readableCondition(trace.condition,$('test-explanation'));}$('result').textContent=JSON.stringify(data.result,null,2);$('activate').disabled=false;});
$('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','',labels[v]||v);opt.value=v;$('trigger').append(opt);});$('trigger').value=definition.trigger;render();await list();});
})();
</script>
{% endblock %}