bmc_hub/static/js/case-command-composer.js
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

390 lines
26 KiB
JavaScript

/* V5 command preview. No command writes happen before the existing Save action. */
(() => {
'use strict';
async function init() {
const form = document.getElementById('comment-form');
const input = form?.querySelector('textarea[name="indhold"]');
if (!input || !window.CaseCommands) return;
const config = JSON.parse(document.getElementById('caseV5Data').textContent);
const saveButton=form.querySelector('button[type="submit"]');
const saveDisabled=saveButton?.disabled;
if(saveButton) saveButton.disabled=true;
let key=null;
try {
const response=await fetch('/api/v1/auth/me',{credentials:'same-origin'});
const actor=response.ok ? await response.json() : null;
if(actor?.id) key=`case-command-draft:${actor.id}:${config.caseId}`;
} catch (_) { /* Without verified actor identity keep the draft only in memory. */ }
const panel = document.createElement('section');
panel.className = 'case-command-panel';
panel.setAttribute('aria-label', 'Kommandoer og forhåndsvisning');
const hint = document.createElement('div');
hint.className = 'case-command-hint';
hint.textContent = 'Skriv / for handlinger.';
const suggestions = document.createElement('div');
suggestions.className = 'case-command-suggestions';
const help = document.createElement('details');
const helpTitle = document.createElement('summary'); helpTitle.textContent='Kommandohjælp';
const helpList=document.createElement('div'); helpList.className='case-command-help';
help.append(helpTitle,helpList);
const chips = document.createElement('div');
chips.className = 'case-command-chips';
const status = document.createElement('div');
status.className = 'case-command-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
const drafts=document.createElement('details');
const draftTitle=document.createElement('summary'); draftTitle.textContent='Mine gemte mailkladder';
const draftList=document.createElement('div'); draftList.className='case-command-drafts';
drafts.append(draftTitle,draftList);
const receipt=document.createElement('div');receipt.className='case-command-receipt';receipt.setAttribute('role','status');receipt.hidden=true;
panel.append(receipt,hint, suggestions, chips, status, help, drafts);
form.querySelector('.comment-composer').after(panel);
const examples = {
tid:'30m remote "Arbejde udført"', tildel:'navn "Begrundelse"', forventet:'1h',
undersag:'"Titel"', mig:'', todo:'"Opgave"', afventer:'kunde "Begrundelse"',
løsning:'"Løsning"', deadline:'i morgen', udsat:'i morgen', fil:'', hjælp:'',
timer:'start "Arbejdsbeskrivelse"', relation:'#1248', prioritet:'høj "Begrundelse"',
gruppe:'Support "Begrundelse"', mail:'"Mailkladde"', note:'"Intern note"'
};
help.className='case-command-help-disclosure';
helpTitle.textContent='/ Hjælp';
const intro=document.createElement('p');intro.className='case-command-help-intro';
intro.textContent='Klik på en kommando for at indsætte et eksempel. Ret teksten og kontrollér handlingerne før gemning.';
helpList.append(intro);
const groups=[
['Tid og planlægning','clock-history',[
['tid','Registrer tid som kladde eller med automatisk afregning'],['timer','Start timer · brug /timer pause for at pause'],
['forventet','Sæt forventet tidsforbrug'],['deadline','Angiv seneste dato'],['udsat','Udsæt sagen inden deadline']]],
['Ansvar og opgaver','person-check',[
['tildel','Vælg medarbejder med begrundelse'],['mig','Tildel sagen til dig selv'],
['gruppe','Overdrag til en gruppe'],['todo','Opret en opgave'],['afventer','Afvent kunden og opret en todo']]],
['Sager og relationer','diagram-3',[
['undersag','Opret en undersag'],['relation','Kobl en eksisterende sag'],['prioritet','Sæt prioritet med begrundelse']]],
['Noter og kommunikation','chat-left-text',[
['note','Gem en intern note'],['løsning','Gem en ny løsning'],['mail','Opret mailkladde · sender ikke'],
['fil','Åbn filvalg · særskilt upload'],['hjælp','Vis denne hjælp']]]
];
groups.forEach(([title,icon,commands])=>{
const section=document.createElement('section');section.className='case-command-help-group';
const heading=document.createElement('h6');
const symbol=document.createElement('i');symbol.className=`bi bi-${icon}`;symbol.setAttribute('aria-hidden','true');
heading.append(symbol,document.createTextNode(title));section.append(heading);
commands.forEach(([name,description])=>{
const button=document.createElement('button');button.type='button';button.className='case-command-help-item';
const command=document.createElement('code');command.textContent=`/${name}`;
const explain=document.createElement('span');explain.textContent=description;
const example=document.createElement('small');example.textContent=`/${name}${examples[name] ? ' '+examples[name] : ''}`;
button.append(command,explain,example);
button.addEventListener('click',()=>{
if(frozenText!==null) {message('Afslut den igangværende gemning før du ændrer kladden.',true);return;}
const prefix=input.value && !input.value.endsWith('\n') ? '\n' : '';
input.value+=`${prefix}/${name}${examples[name] ? ' '+examples[name] : ''}`;
input.focus();input.setSelectionRange(input.value.length,input.value.length);
update();
});section.append(button);
});helpList.append(section);
});
const scopeHelp=document.createElement('div');scopeHelp.className='case-command-help-scope';
const scopeTitle=document.createElement('strong');scopeTitle.textContent='Handlinger på en undersag';
const scopeText=document.createElement('p');scopeText.textContent='/ gælder hovedsagen. // gælder den seneste undersag ovenover.';
const scopeExample=document.createElement('pre');scopeExample.textContent='/undersag "Router opsætning"\n//tildel mkk "Opsæt router"\n//forventet 30m';
scopeHelp.append(scopeTitle,scopeText,scopeExample);helpList.append(scopeHelp);
let parsed, requestId = null, frozenText = null, frozenBody = null, timer, generation = 0, serverValid = false;
let suggestionIndex=-1;
const settlements=new Map();
let workflowActive=false;
const billingChoices=[['draft','Kladde · chefkontrol'],['operations','Drift · chef vælger afregning'],['development','Udvikling · chef vælger afregning']];
async function loadBillingChoices() {
try {
const response=await fetch(`/api/v1/timetracking/cases/${config.caseId}/billing-options`,{credentials:'same-origin'});
if(!response.ok) return;
const data=await response.json();
workflowActive=data.workflow_active === true;
if(workflowActive){if(frozenText===null)update();return;}
if(data.has_customer) billingChoices.push(['invoice',`Faktura · ${Number(data.hourly_rate).toLocaleString('da-DK')} kr./time`]);
(data.prepaid_cards || []).forEach(card=>billingChoices.push([`card_${card.id}`,`Klippekort #${card.card_number || card.id} · ${Number(card.remaining_hours).toLocaleString('da-DK')} timer tilbage`]));
(data.agreements || []).forEach(agreement=>billingChoices.push([`fpa_${agreement.id}`,`Fastpris #${agreement.agreement_number || agreement.id}`]));
if(frozenText===null) update();
} catch (_) { /* The three safe chef-review choices remain available. */ }
}
let storageAvailable = Boolean(key);
function store() {
if (!storageAvailable) return;
try { sessionStorage.setItem(key, JSON.stringify({text:input.value, requestId, frozenText, frozenBody,settlements:[...settlements]})); }
catch (_) { storageAvailable = false; }
}
try {
const saved = key ? JSON.parse(sessionStorage.getItem(key) || 'null') : null;
if (saved && !input.value && typeof saved.text === 'string') {
input.value = saved.text; requestId = saved.requestId || null; frozenText = saved.frozenText || null; frozenBody=saved.frozenBody || null;
if(frozenBody&&frozenBody.billing_mode!=='automatic')message('En tidligere gemning med afregningsvalg afventer svar. Prøv den samme gemning igen; nye registreringer gemmes derefter uden afregningsvalg.');
if(Array.isArray(saved.settlements)) saved.settlements.forEach(([id,value])=>{if(typeof id==='string' && typeof value==='string') settlements.set(id,value);});
}
} catch (_) { storageAvailable = false; }
function currentLine() {
const start = input.value.lastIndexOf('\n', Math.max(0,input.selectionStart-1)) + 1;
const end = input.value.indexOf('\n', input.selectionStart);
return {start, end:end < 0 ? input.value.length : end, text:input.value.slice(start,end < 0 ? undefined : end)};
}
function editLine(line) {
const rows = input.value.split('\n');
const start = rows.slice(0,line-1).reduce((sum,row) => sum+row.length+1,0);
input.focus(); input.setSelectionRange(start,start+(rows[line-1] || '').length);
}
function message(text,error=false) {
status.textContent = text; status.classList.toggle('is-error',error);
}
function body() { return frozenBody || {comment:parsed.comment, commands:parsed.commands, internal:true,billing_mode:'automatic'}; }
function label(command) {
const names={time:'Tid',assign:'Tildel',estimate:'Estimat',subcase:'Opret undersag',self:'Tildel mig',todo:'Todo',waiting:'Afventer kunde',solution:'Løsning',deadline:'Deadline',defer:'Udsæt',file:'Vedhæft filer',help:'Hjælp',timer:'Timer',relation:'Relation',priority:'Prioritet',group:'Gruppe',mail:'Mailkladde',note:'Intern note'};
const target=command.scope==='main' ? 'Hovedsag' : `Undersag ${Number(command.scope)+1}`;
const details=[command.minutes ? `${command.minutes} min` : '',command.category,command.user_name || command.assignee_name || command.assignee,command.group_name || command.group,command.title,command.date,command.priority,command.caseId ? `#${command.caseId}` : '',command.action].filter(Boolean);
return `${names[command.type] || command.type}${details.length ? ' · '+details.join(' · ') : ''} — ${target}`;
}
async function api(path,payload) {
const response = await fetch(`/api/v1/sag/${config.caseId}/${path}`, {
method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
const details = result.detail;
const error = new Error(typeof details === 'string' ? details : details?.errors?.map(e => `Linje ${e.line}: ${e.message}`).join('\n') || `Handlingen fejlede (HTTP ${response.status}).`);
error.status = response.status; throw error;
}
return result;
}
async function preview(version) {
if (!parsed.commands.length || parsed.errors.length) return;
try {
const result = await api('command-preview',body());
if (version !== generation) return;
if (result.errors?.length) { message(result.errors.map(e => `Linje ${e.line}: ${e.message}`).join('\n'),true); return; }
if(result.commands) result.commands.forEach(command => {
const chip=chips.querySelector(`[data-line="${Number(command.line)}"]`);
if(chip) chip.textContent=label(command);
});
serverValid = true; message('Forhåndsvisning klar. Gem udfører handlingerne; mail åbnes kun som kladde.');
} catch (error) { if(version === generation) message(error.message,true); }
}
let completionValues={users:[],groups:config.groups || [],cases:[]};
suggestions.setAttribute('aria-label','Forslag til næste værdi');
async function loadCompletionValues() {
try {
const response=await fetch(`/api/v1/sag/${config.caseId}/command-options`,{credentials:'same-origin'});
if(response.ok) completionValues=await response.json();
renderSuggestions();
} catch(_) { /* Fixed values remain available when the catalog cannot load. */ }
}
function renderSuggestions() {
suggestions.replaceChildren(); suggestionIndex=-1;
if(frozenText!==null || !window.CaseCommandSuggestions) return;
const line=currentLine();
const options=window.CaseCommandSuggestions.suggest(line.text,input.selectionStart-line.start,completionValues);
options.forEach(option=>{
const button=document.createElement('button');button.type='button';
const title=document.createElement('strong');title.textContent=option.label;
const detail=document.createElement('small');detail.textContent=option.hint;
button.append(title,detail);
button.addEventListener('click',()=>{
if(frozenText!==null)return;
input.setRangeText(option.value,line.start+option.from,line.start+option.to,'end');
if(option.caret!==undefined) {
const caret=line.start+option.from+option.caret;
input.setSelectionRange(caret,caret);
}
input.focus();update();
});suggestions.append(button);
});
}
function update() {
generation++; clearTimeout(timer); serverValid = false;
// Insert the required text delimiters as soon as the preceding
// argument is complete, for both typing and menu selections.
if (frozenText === null && window.CaseCommandSuggestions) {
const line = currentLine();
const cursor = input.selectionStart - line.start;
if (cursor === line.text.length && /\s$/.test(line.text)) {
const textOption = window.CaseCommandSuggestions.suggest(line.text, cursor, completionValues)
.find(option => option.value === '"" ' && option.from === cursor);
if (textOption) {
input.setRangeText('""', input.selectionStart, input.selectionStart, 'end');
const caret = line.start + cursor + 1;
input.setSelectionRange(caret, caret);
}
}
}
parsed = window.CaseCommands.parse(input.value,{now:new Date()});
chips.replaceChildren();
parsed.commands.forEach(command => {
const chip = document.createElement('button'); chip.type = 'button';
chip.dataset.line=String(command.line);
chip.textContent = label(command);
chip.title = 'Klik for at rette kommandolinjen';
chip.addEventListener('click', () => editLine(command.line)); chips.append(chip);
if(command.type==='time') {
delete command.settlement_choice;
const note=document.createElement('span');note.className='small text-muted';note.textContent=workflowActive?'Afregning vælges automatisk':'Tid gemmes som kladde · afregning vælges i Tidsregistrering';chips.append(note);
}
if (command.type === 'file') {
const file = document.createElement('button'); file.type='button'; file.textContent='Vælg filer…';
file.addEventListener('click', () => document.getElementById('topbarAttachmentInput')?.click());
chips.append(file);
}
});
renderSuggestions(); store(); updateSubmitLabel();
if(parsed.commands.some(command=>command.type==='help')) help.open=true;
if (frozenText !== null) { input.readOnly = true; message(frozenBody&&frozenBody.billing_mode!=='automatic'?'En tidligere gemning med afregningsvalg afventer svar. Tryk Gem igen for at fortsætte den samme gemning sikkert.':'En gemning afventer svar. Tryk Gem igen for at fortsætte sikkert.'); return; }
if (parsed.errors.length) message(parsed.errors.map(e=>`Linje ${e.line}: ${e.message}`).join('\n'),true);
else if (parsed.commands.length) { message('Kontrollerer handlinger…'); const version=generation; timer=setTimeout(()=>preview(version),350); }
else message('');
}
function reset() {
requestId=null; frozenText=null; frozenBody=null; input.readOnly=false; input.value='';
settlements.clear();
try { if(key) sessionStorage.removeItem(key); } catch (_) {}
update();
}
function openMailDraft(text) {
window.openCaseEmailTab?.();
const bodyInput = document.getElementById('caseEmailBody');
if (bodyInput) bodyInput.value = text;
const modal = document.getElementById('caseEmailComposeModal');
if (modal && window.bootstrap?.Modal) window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
function receiptSummary(result) {
const counts={}; (result.results || []).forEach(item=>{if(!['help','file'].includes(item.type)) counts[item.type]=(counts[item.type] || 0)+1;});
const names={comment:'kommentar',subcase:'undersag',time:'tidsregistrering',todo:'todo',assign:'tildeling',self:'tildeling',group:'gruppeskift',mail:'mailkladde',note:'note',solution:'løsning',relation:'relation',timer:'timerhandling',estimate:'estimat',deadline:'deadline',defer:'udsættelse',priority:'prioritet',waiting:'afventer'};
const parts=Object.entries(counts).map(([type,count])=>`${count} ${names[type] || type}`);
return parts.length ? `Gemt: ${parts.join(', ')}.` : 'Handlingerne er behandlet.';
}
function showReceipt(result) {
receipt.replaceChildren(); receipt.hidden=false;
const summary=document.createElement('div');summary.textContent=receiptSummary(result);receipt.append(summary);
(result.results || []).filter(item=>['subcase','time','mail'].includes(item.type)).forEach(item=>{
const caseId=Number(item.type==='subcase' ? item.id : item.case_id);
if(!Number.isSafeInteger(caseId) || caseId<1) return;
const link=document.createElement('a');link.href=`/sag/${caseId}/v5${item.type==='time' ? '#timetracking' : ''}`;
link.textContent=item.type==='subcase' ? `Åbn undersag #${caseId}` : item.type==='time' ? `Tid #${item.id} på sag #${caseId}` : `Mailkladde #${item.id} på sag #${caseId}`;
receipt.append(link);
});
}
async function loadDrafts() {
try {
const response=await fetch(`/api/v1/sag/${config.caseId}/command-mail-drafts`,{credentials:'same-origin'});
if(!response.ok) throw new Error('Mailkladder kunne ikke hentes.');
const rows=await response.json(); draftList.replaceChildren();
draftTitle.textContent=`Mine gemte mailkladder (${rows.length})`;
drafts.hidden=!rows.length;
if(!rows.length) draftList.textContent='Ingen gemte mailkladder.';
rows.forEach(draft=>{
const button=document.createElement('button'); button.type='button';
button.textContent=`Åbn kladde #${draft.id} · ${draft.subject || 'Uden emne'}`;
button.addEventListener('click',()=>{
openMailDraft(draft.body || '');
const subject=document.getElementById('caseEmailSubject'); if(subject) subject.value=draft.subject || '';
}); draftList.append(button);
});
} catch(error) {draftList.textContent=error.message;}
}
drafts.addEventListener('toggle',()=>{if(drafts.open) loadDrafts();});
function updateSubmitLabel() {
if (/^\s*\//m.test(input.value)) {
const button=form.querySelector('button[type="submit"]');
if(button) button.textContent=frozenText !== null ? 'Prøv gemning igen' : 'Gem kommentar og handlinger';
}
}
window.CaseCommandComposer = {
hasCommands:()=>/^\s*\//m.test(input.value), getParsed:()=>parsed, reset, updateSubmitLabel,
async submit() {
clearTimeout(timer);
if(parsed.errors.length) throw new Error(parsed.errors.map(e=>`Linje ${e.line}: ${e.message}`).join('\n'));
if(!parsed.commands.length) throw new Error('Ingen gyldige kommandoer.');
const wasFrozen=frozenText!==null;
frozenBody=body(); frozenText=input.value; input.readOnly=true;
chips.querySelectorAll('select,button').forEach(control=>{control.disabled=true;});
try {
if (!serverValid && !wasFrozen) {
const result=await api('command-preview',body());
if(result.errors?.length) throw new Error(result.errors.map(e=>e.message).join('\n'));
}
if(!requestId) requestId=crypto.randomUUID();
store();
const result=await api('command-execute',{...body(),request_id:requestId});
const mail=parsed.commands.find(c=>c.type==='mail');
const childMails=(result.results || []).filter(item=>item.type==='mail' && Number(item.case_id)!==Number(config.caseId));
reset();
showReceipt(result);
try {if(key) sessionStorage.setItem(`${key}:receipt`,JSON.stringify(result));} catch(_) {}
await loadDrafts();
if(mail) drafts.open=true;
if(mail && mail.scope==='main') openMailDraft(mail.text);
childMails.forEach(item=>{
const link=document.createElement('a');link.href=`/sag/${Number(item.case_id)}/v5`;
link.textContent=`Åbn mailkladde på undersag #${item.case_id}`;panel.append(link);
});
return {ok:true,message:receiptSummary(result),reload:!mail,result};
} catch(error) {
if(!requestId || (error.status && error.status < 500)) {
requestId=null; frozenText=null; frozenBody=null; input.readOnly=false; store();
chips.querySelectorAll('select,button').forEach(control=>{control.disabled=false;});
}
message(error.message,true); throw error;
}
}
};
input.addEventListener('input',update);
input.addEventListener('click',renderSuggestions);
input.addEventListener('keyup',event=>{if(!['ArrowDown','ArrowUp','Enter','Escape'].includes(event.key)) renderSuggestions();});
input.addEventListener('keydown',event=>{
const options=[...suggestions.querySelectorAll('button')];
if (event.key === 'Enter' && suggestionIndex < 0 && !event.shiftKey
&& !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing
&& frozenText === null && input.selectionStart === input.selectionEnd) {
const line = currentLine();
const cursor = input.selectionStart - line.start;
if (/^\s*\/\/?\S+/.test(line.text)) {
let opening = -1;
for (let index = 0; index < line.text.length; index++) {
if (line.text[index] === '\\' && opening >= 0
&& ['"', '\\'].includes(line.text[index + 1])) { index++; continue; }
if (line.text[index] !== '"') continue;
if (opening < 0) opening = index;
else {
if (cursor > opening && cursor <= index) {
event.preventDefault();
const end = line.start + line.text.length;
input.setRangeText('\n', end, end, 'end');
update();
return;
}
opening = -1;
}
}
}
}
if(!options.length) return;
if(event.key==='Escape') {suggestions.replaceChildren();suggestionIndex=-1;return;}
if(['ArrowDown','ArrowUp'].includes(event.key)) {
event.preventDefault(); suggestionIndex=(suggestionIndex+(event.key==='ArrowDown'?1:-1)+options.length)%options.length;
options.forEach((option,index)=>{option.classList.toggle('is-active',index===suggestionIndex);option.setAttribute('aria-pressed',String(index===suggestionIndex));});
options[suggestionIndex].scrollIntoView({block:'nearest'});
} else if(event.key==='Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey
&& !event.altKey && !event.isComposing && frozenText===null
&& input.selectionStart===input.selectionEnd) {
event.preventDefault();options[suggestionIndex>=0?suggestionIndex:0].click();
}
});
update();
try {
const previous=key ? JSON.parse(sessionStorage.getItem(`${key}:receipt`) || 'null') : null;
if(previous?.results) {showReceipt(previous);sessionStorage.removeItem(`${key}:receipt`);}
} catch(_) {}
if(saveButton) saveButton.disabled=Boolean(saveDisabled);
loadDrafts();
loadBillingChoices();
loadCompletionValues();
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init); else init();
})();