bmc_hub/app/workflows/frontend/builder.html

56 lines
20 KiB
HTML
Raw Normal View History

{% 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><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:'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;}
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'},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?'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;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,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):'Afventer manuel vurdering');$('test-explanation').append(summary);for(const trace of data.result.trace||[]){$('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 %}