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

553 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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='<label for="v5QuickBilling" class="form-label small">Afregning</label><select id="v5QuickBilling" class="form-select form-select-sm" disabled><option>Indlæser afregning…</option></select><div id="v5BillingHint" class="form-text">Kladder kontrolleres af en chef før afregning.</div>';
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='<i class="bi bi-plus-lg me-1"></i>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 = `<i class="bi bi-${icon}" aria-hidden="true"></i><span>${label}</span>`;
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 = '<i class="bi bi-display" aria-hidden="true"></i><span>AnyDesk</span>';
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 = '<i class="bi bi-plus-lg" aria-hidden="true"></i>';
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 = '<i class="bi bi-telephone-outbound" aria-hidden="true"></i>';
}
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 = '<i class="bi bi-plus-lg" aria-hidden="true"></i><span>Handling</span>';
const commandFilterLabel = document.createElement('label');
commandFilterLabel.className = 'comment-category-check';
commandFilterLabel.title = 'Vis eller skjul /-kommandoer';
commandFilterLabel.innerHTML = '<input type="checkbox" id="commentShowCommands" checked aria-label="Vis /-kommandoer"><i class="bi bi-slash-lg" aria-hidden="true"></i><span id="commentSourceCountCommands" class="count">0</span>';
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 = `<i class="bi bi-${action[2]} me-1"></i>`;
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();
})();