/* 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'); shell.prepend(byId('caseV5Tools').content.cloneNode(true)); const board = document.createElement('div'); board.className = 'v5-board'; const facts = document.createElement('aside'); facts.className = 'v5-case-facts'; facts.setAttribute('aria-label', 'Kunde og sagsoplysninger'); const center = document.createElement('main'); const modules = document.createElement('aside'); modules.className = 'v5-case-modules'; modules.setAttribute('aria-label', 'Todo, filer og relationer'); const tabs = byId('caseTabs'); tabs.before(board); board.append(facts, center, modules); const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')]; [3, 1, 0, 2].forEach(index => { if (widgets[index]) facts.append(widgets[index]); }); 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')); 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); const rightGrid = right.querySelector('.right-modules-grid'); ['todo-steps', 'files', 'relations', 'hardware', 'time'].reverse().forEach(key => { const card = shell.querySelector(`.right-module-card[data-module="${key}"]`); if (card) rightGrid.prepend(card); }); // 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"]')); let assignSelected = false; let groupId = config.groupId; let userId = config.userId; let members = []; let memberRequest = 0; let membersLoading = false; 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'; } function toggleAction(action) { if (busy || pending) return; if (action[0] === 'assign') { assignSelected = !assignSelected; if (assignSelected && groupId) loadMembers(); } else { const checkbox = byId(action[3]); checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change', {bubbles: true})); } renderActions(); } 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 renderUsers() { const items = [{id: null, name: 'Kun gruppen'}, ...members.map(member => ({id: member.user_id, name: member.display_name}))]; renderPicker(byId('v5UserOptions'), items, userId, id => { userId = id; renderUsers(); }, byId('v5UserSearch').value); renderActions(); } async function loadMembers() { const sequence = ++memberRequest; membersLoading = true; byId('v5UserSearch').disabled = true; byId('v5UserOptions').textContent = 'Henter medarbejdere…'; try { const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${groupId}`, {credentials: 'include'}); if (!response.ok) throw new Error('Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.'); const data = await response.json(); if (sequence !== memberRequest) return; members = data.members || []; if (!members.some(member => member.user_id === userId)) userId = null; byId('v5UserSearch').disabled = false; byId('v5UserSearch').placeholder = 'Find medarbejder…'; 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); function lock(locked) { form.querySelectorAll('input, textarea, select, button').forEach(el => { el.disabled = locked; }); // 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.removeAttribute('onsubmit'); form.addEventListener('submit', async event => { event.preventDefault(); if (busy) 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') && (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.'); 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(); })();