/* 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(); })();