341 lines
24 KiB
JavaScript
341 lines
24 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 / på en ny linje for handlinger. Gem udfører kommandoerne. Vælg filer uploader særskilt med det samme.';
|
|
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='Kommandoer · hjælp og eksempler';
|
|
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');
|
|
input.focus();update();
|
|
});suggestions.append(button);
|
|
});
|
|
}
|
|
function update() {
|
|
generation++; clearTimeout(timer); serverValid = false;
|
|
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})`;
|
|
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(!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' && suggestionIndex>=0) {event.preventDefault();options[suggestionIndex].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();
|
|
})();
|