/* V5 composes the established V4 controls without copying their implementations. */ (() => { 'use strict'; const config = JSON.parse(document.getElementById('caseV5Data').textContent); const byId = id => document.getElementById(id); const shell = document.querySelector('.case-detail-page-shell'); shell.classList.add('case-v5'); const billingHost = document.createElement('div'); billingHost.className='col-12'; billingHost.innerHTML='
Kladder kontrolleres af en chef før afregning.
'; byId('commentQuickTimeRow').querySelector('.row').append(billingHost); const compactBillingHost=billingHost.cloneNode(true); compactBillingHost.querySelector('label').htmlFor='v5CompactBilling'; compactBillingHost.querySelector('select').id='v5CompactBilling'; compactBillingHost.querySelector('.form-text').removeAttribute('id'); byId('caseQuickTimeDraftForm').querySelector('.row').insertBefore(compactBillingHost,byId('caseQuickTimeSaveButton').parentElement); byId('caseQuickTimeSaveButton').innerHTML='Gem tid'; function setAutomaticRegistrationControls(workflowActive = false) { const text=workflowActive?'Afregning vælges automatisk':'Tid gemmes som kladde. Afregning vælges i Tidsregistrering.'; for(const select of [byId('v5QuickBilling'),byId('v5CompactBilling'),byId('time_billing_method'),byId('quickTimeBillingMethod')].filter(Boolean)) { select.replaceChildren(new Option('Automatisk / kladde','draft')); select.value='draft';select.disabled=true;select.hidden=true;select.dataset.billingReady='true'; const label=select.parentElement.querySelector(`label[for="${select.id}"]`);if(label)label.hidden=true; } for(const host of [billingHost,compactBillingHost])host.querySelector('.form-text').textContent=text; for(const id of ['time_internal','sol_time_internal']){const checkbox=byId(id);if(checkbox){checkbox.checked=false;checkbox.disabled=true;const wrapper=checkbox.closest('.form-check');if(wrapper)wrapper.hidden=true;}} } setAutomaticRegistrationControls(); const previewFields=[ {host:compactBillingHost,minutes:'caseQuickTimeMinutes',description:'caseQuickTimeDescription',type:'caseQuickTimeType'}, {host:billingHost,minutes:'commentQuickTimeMinutes',description:'commentText',type:'commentQuickTimeType'} ]; let previewTimer; async function refreshBillingPreview() { const names={subscription:'Fastpris',prepaid:'Klippekort',invoice:'Faktura',internal:'Intern tid',non_billable:'Ikke fakturerbar',draft:'Kladde'}; await Promise.all(previewFields.map(async fields=>{ const sequence=(fields.sequence||0)+1;fields.sequence=sequence; const hint=fields.host.querySelector('.form-text'); const params=new URLSearchParams({minutes:Math.max(1,Number(byId(fields.minutes)?.value)||30),description:(byId(fields.description)||document.querySelector('textarea[name=indhold]'))?.value||'',time_type:byId(fields.type)?.value||'support'}); try { const response=await fetch(`/api/v1/timetracking/cases/${config.caseId}/billing-preview?${params}`,{credentials:'include'}); const data=await response.json();if(!response.ok)throw new Error('Preview failed'); if(sequence!==fields.sequence)return; hint.textContent='Forslag: '+(data.status==='manual_review'?'Manuel vurdering':names[data.method]||'Manuel vurdering'); } catch(error) {if(sequence===fields.sequence)hint.textContent='Afregningsforslag kunne ikke beregnes.';} })); } function scheduleBillingPreview(){clearTimeout(previewTimer);previewTimer=setTimeout(refreshBillingPreview,250);} for(const fields of previewFields)for(const id of [fields.minutes,fields.description,fields.type]){ const input=byId(id)||(id==='commentText'?document.querySelector('textarea[name=indhold]'):null);if(input){input.addEventListener('input',scheduleBillingPreview);input.addEventListener('change',scheduleBillingPreview);input.addEventListener('focus',scheduleBillingPreview);} } const tagList=byId('case-tags-module'); if(tagList)new MutationObserver(scheduleBillingPreview).observe(tagList,{childList:true,subtree:true}); async function loadBillingOptions() { try { const response=await fetch(`/api/v1/timetracking/cases/${config.caseId}/billing-options`,{credentials:'include'}); const data=await response.json(); if(!response.ok)throw new Error(data.detail || 'Afregningsmuligheder kunne ikke hentes'); setAutomaticRegistrationControls(data.workflow_active===true); await refreshBillingPreview(); const anchor=byId('timeV1Date'); if(anchor&&!byId('v5TabBilling')) { const host=document.createElement('div');host.className='col-12'; const label=document.createElement('label');label.htmlFor='v5TabBilling';label.className='form-label small';label.textContent='Afregning (valgfrit)'; const select=document.createElement('select');select.id='v5TabBilling';select.className='form-select form-select-sm'; select.add(new Option('Kladde / automatisk','draft')); if(data.workflow_active!==true){if(data.has_customer)select.add(new Option('Faktura','invoice'));(data.prepaid_cards||[]).forEach(c=>select.add(new Option('Klippekort '+(c.card_number||c.id),'card_'+c.id)));(data.agreements||[]).forEach(a=>select.add(new Option('Fastpris '+(a.agreement_number||a.id),'fpa_'+a.id)));select.add(new Option('Intern tid','internal'));select.add(new Option('Ikke fakturerbar','non_billable'));} const hint=document.createElement('div');hint.className='form-text';hint.textContent=data.workflow_active?'Afregningen vælges af workflows. Brug afregningsbeslutningen nedenfor for en begrundet ændring.':'Manuel afregning kan kun vælges her i Tidsregistrering.'; host.append(label,select,hint);anchor.closest('.row')?.append(host); } } catch(error){byId('v5BillingHint').textContent='Tid kan stadig registreres som kladde. Afregningsmuligheder kunne ikke hentes.';} } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadBillingOptions,{once:true});else loadBillingOptions(); const deferredInput = byId('topbarDeferredInput'); const deadlineInput = byId('topbarDeadlineInput'); function syncDateLimits() { if (deadlineInput.value) deferredInput.max = deadlineInput.value; else deferredInput.removeAttribute('max'); if (deferredInput.value) deadlineInput.min = deferredInput.value; else deadlineInput.removeAttribute('min'); } [deferredInput, deadlineInput].forEach(input => input.addEventListener('change', event => { if (deferredInput.value && deadlineInput.value && deferredInput.value > deadlineInput.value) { event.stopImmediatePropagation(); input.value = input.defaultValue; showCaseFeedback('Udsat må ikke ligge efter deadline', 'danger'); } syncDateLimits(); }, true)); syncDateLimits(); const board = document.createElement('div'); board.className = 'v5-board'; const center = document.createElement('main'); const modules = document.createElement('aside'); modules.className = 'v5-case-modules'; modules.setAttribute('aria-label', 'Todo, filer og moduler'); const tabs = byId('caseTabs'); tabs.before(board); board.append(center, modules); const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')]; // Move the original controls into hero menus: handlers, IDs and values survive. const factsMenu = document.createElement('nav'); factsMenu.className = 'v5-hero-facts-menu'; factsMenu.setAttribute('aria-label', 'Kontakt, tildeling og klassifikation'); [[3, 'Kontakt', 'person-vcard'], [1, 'Tildeling', 'people'], [0, 'Klassifikation', 'tags']].forEach(([index, label, icon]) => { const card = widgets[index]; if (!card) return; const dropdown = document.createElement('div'); dropdown.className = 'dropdown'; const button = document.createElement('button'); button.type = 'button'; button.id = `v5HeroFacts${index}`; button.className = 'v5-hero-facts-button dropdown-toggle'; button.dataset.bsToggle = 'dropdown'; button.dataset.bsAutoClose = 'outside'; button.setAttribute('aria-expanded', 'false'); button.setAttribute('aria-controls', `v5HeroFactsPanel${index}`); button.innerHTML = `${label}`; const panel = document.createElement('div'); panel.id = `v5HeroFactsPanel${index}`; panel.className = 'dropdown-menu v5-hero-facts-panel v5-case-facts'; panel.setAttribute('aria-labelledby', button.id); panel.append(card); dropdown.append(button, panel); factsMenu.append(dropdown); // Open secondary dialogs without leaving this popup above their backdrop. panel.addEventListener('click', event => { const target = event.target.closest('button, a'); if (target && !target.matches('[data-bs-toggle="dropdown"]')) { bootstrap.Dropdown.getOrCreateInstance(button).hide(); } }); }); shell.querySelector('.case-header-copy').append(factsMenu); const anyDeskButton = document.createElement('button'); anyDeskButton.id = 'v5AnyDeskOpenButton'; anyDeskButton.type = 'button'; anyDeskButton.className = 'v5-anydesk-button'; anyDeskButton.title = 'Forbind med AnyDesk'; anyDeskButton.innerHTML = 'AnyDesk'; anyDeskButton.addEventListener('click', () => window.openCaseAnyDeskModal()); shell.querySelector('.case-header-identity')?.append(anyDeskButton); const heroStatus = document.createElement('div'); heroStatus.className = 'v5-hero-status'; ['topbarStatusSelect', 'topbarPrioritySelect'].forEach(id => { const row = byId(id)?.parentElement; if (row) heroStatus.append(row); }); shell.querySelector('.case-header-main').insertBefore(heroStatus, shell.querySelector('.case-header-todo')); const heroTodo = shell.querySelector('.case-header-todo'); if (heroTodo) { const addTodo = document.createElement('button'); addTodo.type = 'button'; addTodo.className = 'v5-hero-add-todo'; addTodo.title = 'Opret todo på sagen'; addTodo.setAttribute('aria-label', 'Opret todo på sagen'); addTodo.innerHTML = ''; addTodo.addEventListener('click', () => window.openRelTodoModal(config.caseId, byId('sag-titel-text')?.textContent.trim() || '')); heroTodo.append(addTodo); } const timeline = byId('topbarStartDateInput')?.closest('.card'); if (timeline) { timeline.classList.add('v5-hero-timeline'); shell.querySelector('.case-header-main').append(timeline); } center.append(tabs, byId('timeActiveBanner'), byId('caseTabsContent')); // Normalize legacy closing tags: browsers can parse later panes outside // their tab-content. Every V5 pane belongs to the same center column. tabs.querySelectorAll('[data-bs-target]').forEach(button => { const pane = document.querySelector(button.getAttribute('data-bs-target')); if (pane && pane.parentElement !== byId('caseTabsContent')) byId('caseTabsContent').append(pane); }); const planner = byId('planner'); if (planner) { planner.hidden = true; planner.style.setProperty('display', 'none', 'important'); } byId('planner-tab')?.closest('.nav-item')?.remove(); // Keep planner markup for existing dialogs, but never expose its pane in V5. if (new URLSearchParams(location.search).get('tab') === 'planner') { const url = new URL(location.href); url.searchParams.set('tab', 'details'); history.replaceState(null, '', url); } const details = byId('details'); const left = byId('case-left-column'); const right = byId('case-right-column'); const detailsRow = document.createElement('div'); detailsRow.className = 'case-v4-details-columns'; details.prepend(detailsRow); detailsRow.append(left); modules.append(right); // Keep the existing call action and data; give the narrow sidebar a list layout. byId('caseCallHistoryRows')?.querySelectorAll('tr').forEach(row => { const cells = row.querySelectorAll('td'); const callButton = cells[2]?.querySelector('button'); if (callButton) { callButton.title = `Ring til ${cells[2].querySelector('span')?.textContent.trim() || 'nummeret'}`; callButton.setAttribute('aria-label', callButton.title); callButton.innerHTML = ''; } if (cells[1]) { const outgoing = cells[1].textContent.trim() === 'Udgående'; const icon = document.createElement('i'); icon.className = `bi bi-arrow-${outgoing ? 'up-right' : 'down-left'}`; icon.setAttribute('aria-hidden', 'true'); cells[1].prepend(icon); } }); const rightGrid = right.querySelector('.right-modules-grid'); const tagsCard = rightGrid?.querySelector('[data-module="tags"]'); if (tagsCard) { rightGrid.prepend(tagsCard); tagsCard.classList.remove('d-none'); tagsCard.style.setProperty('display', 'flex', 'important'); } ['todo-steps', 'files', 'hardware', 'time'].reverse().forEach(key => { const card = shell.querySelector(`.right-module-card[data-module="${key}"]`); if (card) rightGrid.prepend(card); }); const relations = shell.querySelector('.right-module-card[data-module="relations"]'); if (relations) { relations.classList.add('v5-center-relations'); const commentsCard = byId('comment-form').closest('.left-module-card'); commentsCard.after(relations); } // V4 refreshes this hook after async module loads. Keep its columns in V5. window.ensureCaseDetailsColumns = () => detailsRow; window.updateRightColumnVisibility = () => right.classList.remove('d-none'); const originalApplyLayout = window.applyViewLayout; window.applyViewLayout = function (...args) { originalApplyLayout(...args); right.classList.remove('d-none'); }; const actions = [ ['assign', 'Tildel sag', 'person-check', null], ['reply_customer', 'Svar kunden', 'envelope', 'commentActionReplyCustomer'], ['log_time', 'Registrer tid', 'clock-history', 'commentActionLogTime'], ['solution', 'Gem som løsning', 'lightbulb', 'commentActionSolution'], ['close_case', 'Luk sag', 'check-circle', 'commentActionCloseCase'], ['await_customer', 'Afventer kunde', 'hourglass-split', 'commentActionAwaitCustomer'], ['append_case', 'Tilføj til sag', 'file-earmark-plus', 'commentActionAppendCase'] ]; const form = byId('comment-form'); const legacyChips = form.querySelector('.comment-action-chips'); legacyChips.before(byId('caseV5ComposerTools').content.cloneNode(true)); byId('v5SelectedActions').parentElement.append(form.querySelector('button[type="submit"]')); // Keep action selection and its fields together above the comment editor. const composer = form.querySelector('.comment-composer'); const actionToolbar = byId('v5ActionToggle').parentElement; const submitButton = form.querySelector('button[type="submit"]'); const submitRow = document.createElement('div'); submitRow.className = 'v5-submit-row'; submitRow.append(submitButton); composer.before(actionToolbar, byId('v5ActionMenu'), byId('v5AssignmentFields'), byId('commentQuickTimeRow')); composer.after(submitRow); byId('v5ActionToggle').innerHTML = 'Handling'; const commandFilterLabel = document.createElement('label'); commandFilterLabel.className = 'comment-category-check'; commandFilterLabel.title = 'Vis eller skjul /-kommandoer'; commandFilterLabel.innerHTML = '0'; shell.querySelector('.comment-toolbar').append(commandFilterLabel); const originalCommentFilters = window.applyCommentFilters; window.applyCommentFilters = function (...args) { originalCommentFilters(...args); const items = [...byId('comments-container').querySelectorAll('.comment-item')]; const commandItems = items.filter(item => { const raw = item.querySelector('.comment-body')?.dataset.commentRaw || ''; return /^\s*\/\/?(?:tid|tildel|forventet|undersag|mig|todo|afventer|løsning|deadline|udsat|fil|hjælp|timer|relation|prioritet|gruppe|mail|note)(?:\s|$)/im.test(raw) || /^(?:deadline|defer|priority):/i.test(raw.trim()); }); byId('commentSourceCountCommands').textContent = String(commandItems.length); if (!byId('commentShowCommands').checked) commandItems.forEach(item => { item.hidden = true; }); const count = items.filter(item => !item.hidden).length; byId('commentsVisibleCountBadge').textContent = `${count}/${items.length} vises`; byId('commentFilterEmptyState')?.classList.toggle('d-none', count > 0 || items.length === 0); }; byId('commentShowCommands').addEventListener('change', () => window.applyCommentFilters()); window.applyCommentFilters(); let assignSelected = false; let groupId = config.groupId; let userId = config.userId; let members = []; let memberRequest = 0; let membersLoading = false; const memberCache = new Map(); const disabledBeforeLock = new Map(); let busy = false; let pending = null; const feedback = (message, error = false) => { const el = byId('v5SubmitFeedback'); el.textContent = message; el.classList.toggle('text-danger', error); }; const selected = action => action[0] === 'assign' ? assignSelected : Boolean(byId(action[3])?.checked); function renderActions() { const host = byId('v5SelectedActions'); host.replaceChildren(); actions.forEach(action => { const active = selected(action); byId(`v5Option-${action[0]}`).setAttribute('aria-pressed', String(active)); if (!active) return; const chip = document.createElement('button'); chip.type = 'button'; let label = action[1]; if (action[0] === 'assign') { const target = members.find(member => member.user_id === userId)?.display_name || config.groups.find(group => group.id === groupId)?.name || 'Vælg modtager'; label = `Tildel: ${target}`; } if (action[0] === 'log_time') label = `Tid: ${byId('commentQuickTimeMinutes').value || '0'} min`; chip.textContent = `${label} ×`; chip.setAttribute('aria-label', `Fjern ${action[1]}`); chip.addEventListener('click', () => toggleAction(action)); chip.disabled = busy || Boolean(pending); host.append(chip); }); byId('v5AssignmentFields').hidden = !assignSelected; form.querySelector('button[type="submit"]').textContent = pending ? 'Fortsæt resterende handlinger' : assignSelected ? 'Gem og tildel' : actions.some(selected) ? 'Gem med handlinger' : 'Send'; window.CaseCommandComposer?.updateSubmitLabel(); } function toggleAction(action) { if (busy || pending) return; if (action[0] === 'assign') { assignSelected = !assignSelected; if (assignSelected) loadMembers(); } else { const checkbox = byId(action[3]); checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change', {bubbles: true})); } renderActions(); closeMenu(); } actions.forEach(action => { const button = document.createElement('button'); button.id = `v5Option-${action[0]}`; button.type = 'button'; button.innerHTML = ``; button.append(document.createTextNode(action[1])); button.addEventListener('click', () => toggleAction(action)); byId('v5ActionOptions').append(button); byId(action[3])?.addEventListener('change', renderActions); }); const toggle = byId('v5ActionToggle'); function closeMenu() { byId('v5ActionMenu').hidden = true; toggle.setAttribute('aria-expanded', 'false'); } toggle.addEventListener('click', () => { const menu = byId('v5ActionMenu'); menu.hidden = !menu.hidden; toggle.setAttribute('aria-expanded', String(!menu.hidden)); }); document.addEventListener('click', event => { if (!byId('v5ActionMenu').contains(event.target) && !toggle.contains(event.target)) closeMenu(); }); form.addEventListener('keydown', event => { if (event.key === 'Escape') { closeMenu(); toggle.focus(); } }); function renderPicker(host, items, current, choose, search) { host.replaceChildren(); const filtered = items.filter(item => item.name.toLocaleLowerCase('da').includes(search.toLocaleLowerCase('da'))); filtered.forEach(item => { const button = document.createElement('button'); button.type = 'button'; button.textContent = item.name; button.setAttribute('aria-pressed', String(item.id === current)); button.disabled = busy || Boolean(pending); button.addEventListener('click', () => choose(item.id)); host.append(button); }); if (!filtered.length) host.textContent = 'Ingen match'; } function renderGroups() { renderPicker(byId('v5GroupOptions'), config.groups, groupId, id => { groupId = id; userId = null; members = []; renderGroups(); loadMembers(); }, byId('v5GroupSearch').value); } function employeeInitials(name) { const words = String(name || '').trim().split(/\s+/).filter(Boolean); return (words.length > 1 ? words[0][0] + words[words.length - 1][0] : (words[0] || '?').slice(0, 2)).toLocaleUpperCase('da'); } function renderUsers() { const host = byId('v5UserOptions'); host.replaceChildren(); const query = byId('v5UserSearch').value.trim().toLocaleLowerCase('da'); members.filter(member => `${member.display_name} ${employeeInitials(member.display_name)}`.toLocaleLowerCase('da').includes(query)).forEach(member => { const button = document.createElement('button'); button.type = 'button'; button.textContent = employeeInitials(member.display_name); button.title = member.display_name; button.setAttribute('aria-label', member.display_name); button.setAttribute('aria-pressed', String(member.user_id === userId)); button.disabled = busy || Boolean(pending); button.addEventListener('click', () => { userId = member.user_id; // Keep the current group when possible; otherwise infer it from // the employee's membership without changing the API contract. groupId = member.group_ids.includes(groupId) ? groupId : member.group_ids[0]; renderGroups(); renderUsers(); }); host.append(button); }); if (!host.children.length) host.textContent = 'Ingen medarbejdere fundet'; renderActions(); } async function loadMembers() { const sequence = ++memberRequest; membersLoading = true; byId('v5UserSearch').disabled = true; byId('v5UserOptions').textContent = 'Henter medarbejdere…'; try { const results = await Promise.all(config.groups.map(async group => { let data = memberCache.get(group.id); if (!data) { const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${group.id}`, {credentials: 'include'}); if (!response.ok) throw new Error('Kunne ikke hente medarbejdere. Åbn Tildel igen for at prøve igen.'); data = await response.json(); memberCache.set(group.id, data); } return {groupId: group.id, members: data.members || []}; })); if (sequence !== memberRequest) return; const employees = new Map(); results.forEach(result => result.members.forEach(member => { if (!employees.has(member.user_id)) employees.set(member.user_id, {...member, group_ids: []}); employees.get(member.user_id).group_ids.push(result.groupId); })); members = [...employees.values()].sort((a,b) => a.display_name.localeCompare(b.display_name, 'da')); if (!members.some(member => member.user_id === userId)) userId = null; byId('v5UserSearch').disabled = false; renderUsers(); feedback(''); } catch (error) { if (sequence === memberRequest) { byId('v5UserOptions').textContent = ''; feedback(error.message, true); } } finally { if (sequence === memberRequest) membersLoading = false; } } byId('v5GroupSearch').addEventListener('input', renderGroups); byId('v5UserSearch').addEventListener('input', renderUsers); byId('commentQuickTimeMinutes').addEventListener('input', renderActions); form.querySelector('textarea[name="indhold"]').addEventListener('input', renderActions); function lock(locked) { form.querySelectorAll('input, textarea, select, button').forEach(el => { if (locked) { if (!disabledBeforeLock.has(el)) disabledBeforeLock.set(el, el.disabled); el.disabled = true; } else if (disabledBeforeLock.has(el)) { el.disabled = disabledBeforeLock.get(el); disabledBeforeLock.delete(el); } }); // Failed submissions remain locked except for retry, avoiding an altered // draft being attached to an already persisted comment. if (pending && !busy) { form.querySelector('button[type="submit"]').disabled = false; if (pending.remaining.includes('log_time')) { byId('commentQuickTimeRow').querySelectorAll('input, select').forEach(el => { el.disabled = false; }); toggleCommentQuickTimeStartField(); } } } form.querySelector('textarea[name="indhold"]').addEventListener('keydown', event => { if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey) || event.isComposing) return; event.preventDefault(); event.stopImmediatePropagation(); const submit = form.querySelector('button[type="submit"]'); if (!busy && submit && !submit.disabled) form.requestSubmit(submit); }); form.querySelector('button[type="submit"]').title = 'Gem kommentar · Cmd+Enter / Ctrl+Enter'; form.removeAttribute('onsubmit'); form.addEventListener('submit', async event => { event.preventDefault(); if (busy) return; const containsCommand = /^\s*\//m.test(form.querySelector('textarea[name="indhold"]').value); if (containsCommand && !window.CaseCommandComposer) { feedback('Kommandofeltet indlæses. Vent et øjeblik og prøv igen.', true); return; } if (window.CaseCommandComposer?.hasCommands()) { if (pending || assignSelected || getSelectedCommentActions().length) { feedback('Fjern handlingerne fra plusmenuen, eller skriv dem som kommandoer, før du gemmer.', true); return; } busy = true; lock(true); feedback('Gemmer kommentar og kommandoer…'); try { const result = await window.CaseCommandComposer.submit(); feedback(result.message); window.dispatchEvent(new CustomEvent('bmc:timer-state-changed')); if (result.reload) reloadCasePreservingContext(); else { if (typeof loadTimeTrackingTab === 'function') loadTimeTrackingTab(); if (typeof loadTodoSteps === 'function') loadTodoSteps(); } } catch (error) { feedback(error.message, true); } finally { busy = false; lock(false); renderActions(); window.CaseCommandComposer.updateSubmitLabel(); } return; } const textarea = form.querySelector('textarea[name="indhold"]'); const content = textarea.value.trim(); const reason = byId('v5AssignmentReason').value.trim(); const chosen = getSelectedCommentActions(); if (!pending) { if (!content) { feedback('Skriv en kommentar først.', true); return; } if (assignSelected && (!groupId || !reason || membersLoading || byId('v5UserSearch').disabled)) { feedback('Vælg gruppe, vent på medarbejderlisten og skriv en begrundelse.', true); return; } if (chosen.includes('log_time') && byId('v5QuickBilling').dataset.billingReady !== 'true') { feedback('Afregningsmuligheder er ikke indlæst. Genindlæs siden.', true); return; } if (chosen.includes('log_time') && (Number(byId('commentQuickTimeMinutes').value) <= 0 || !byId('commentQuickTimeDate').value)) { feedback('Vælg dato og et positivt minuttal før tidsregistrering.', true); return; } if (chosen.includes('close_case')) { busy = true; lock(true); let confirmed = false; try { confirmed = await confirmCaseClosureIfNeeded({timeWillBeRegistered: chosen.includes('log_time')}); } finally { busy = false; lock(false); } if (!confirmed) return; } } busy = true; lock(true); feedback('Gemmer…'); try { if (!pending) { const url = `/api/v1/sag/${config.caseId}/${assignSelected ? 'assign-with-reason' : 'kommentarer'}`; const internal = assignSelected || chosen.includes('solution') || chosen.includes('log_time'); const body = assignSelected ? {group_id: groupId, primary_user_id: userId, reason, content} : {indhold: content, er_intern: internal}; const response = await fetch(url, {method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); const result = await response.json(); if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kunne ikke gemme kommentaren.'); const comment = assignSelected ? result.comment : result; pending = {content, remaining: chosen.slice(), assigned: assignSelected}; let visibleContent = comment.indhold || content; if (assignSelected) { const decoder = document.createElement('textarea'); decoder.innerHTML = visibleContent; visibleContent = decoder.value; } injectCreatedComment(comment, visibleContent, internal); } // Finish one action at a time. Retry never creates another comment // or repeats actions that already returned successfully. while (pending.remaining.length) { await applyCommentActionPrefill(pending.remaining[0], pending.content); pending.remaining.shift(); } pending = null; textarea.value = ''; byId('v5AssignmentReason').value = ''; assignSelected = false; clearSelectedCommentActions(); feedback('Kommentar og handlinger gemt.'); window.CaseCommandComposer?.reset(); reloadCasePreservingContext(); } catch (error) { feedback(pending ? `Kommentaren er gemt. En handling mangler: ${error.message}. Brug Fortsæt for at prøve den igen.` : error.message, true); } finally { busy = false; lock(Boolean(pending)); renderActions(); } }); renderGroups(); renderActions(); applyCommentFilters(); })();