bmc_hub/static/js/case-v5.js
Christian 1fb5b4a191 Add comprehensive tests for email and drift functionalities
- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements.
- Added tests for email identity API to validate email linking and identity retrieval without updates.
- Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios.
- Created tests for email reply automation to ensure proper routing and processing of replies based on classification.
- Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies.
- Added tests for email sender identity to ensure correct matching of contacts and domains.
- Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2026-11-03 01:16:12 +01:00

633 lines
38 KiB
JavaScript
Raw Permalink 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')];
// Show current facts directly; reuse the established controls when editing.
const factsMenu = document.createElement('nav');
factsMenu.className = 'v5-hero-facts-menu';
factsMenu.setAttribute('aria-label', 'Kontakt, tildeling og klassifikation');
function heroFactValue(index) {
const selected = id => byId(id)?.selectedOptions?.[0]?.textContent.trim() || '';
if (index === 3) {
const card = widgets[3];
return [card?.querySelector('.contact-widget-company .contact-widget-value')?.textContent.trim(),
card?.querySelector('.contact-widget-primary .contact-widget-value')?.textContent.trim()].filter(Boolean).join(' · ') || 'Vælg kontakt';
}
if (index === 1) return [selected('tabsAssignmentUserSelect'), selected('tabsAssignmentGroupSelect')].filter(Boolean).join(' · ') || 'Ingen tildeling';
return selected('topbarTypeSelect') || 'Vælg type';
}
[[3, 'Kontakt', 'person-vcard'], [1, 'Tildeling', 'people'], [0, 'Klassifikation', 'tags']].forEach(([index, label, icon]) => {
const card = widgets[index];
if (!card) return;
const button = document.createElement('button');
button.type = 'button';
button.id = `v5HeroFacts${index}`;
button.className = 'v5-hero-facts-button';
button.setAttribute('aria-haspopup', 'dialog');
button.setAttribute('aria-controls', `v5HeroFactsPanel${index}`);
button.innerHTML = `<i class="bi bi-${icon}" aria-hidden="true"></i><span class="v5-fact-copy"><span class="v5-fact-label">${label}</span><span class="v5-fact-value"></span></span><i class="bi bi-pencil v5-fact-edit" aria-hidden="true"></i>`;
const value = button.querySelector('.v5-fact-value');
const sync = () => {value.textContent = heroFactValue(index); button.setAttribute('aria-label', `${label}: ${value.textContent}. Klik for at ændre`);};
const panel = document.createElement('div');
panel.id = `v5HeroFactsPanel${index}`;
panel.className = 'modal v5-facts-editor case-v5';
panel.tabIndex = -1;
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('aria-labelledby', `v5FactsTitle${index}`);
panel.innerHTML = `<div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="v5FactsTitle${index}">Redigér ${label.toLocaleLowerCase('da-DK')}</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body v5-case-facts"></div></div></div>`;
panel.querySelector('.modal-body').append(card);
document.body.append(panel);
button.addEventListener('click', () => bootstrap.Modal.getOrCreateInstance(panel).show());
panel.addEventListener('hidden.bs.modal', sync);
panel.addEventListener('change', sync);
// Close synchronously before an existing control opens its own editor.
card.addEventListener('click', event => {
if (event.target.closest('button, a')) bootstrap.Modal.getOrCreateInstance(panel).hide();
}, true);
new MutationObserver(sync).observe(card, {subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['selected', 'data-current-type']});
factsMenu.append(button);
sync();
});
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);
});
// Keep the case plan primary; existing subcases remain available on demand.
const subcaseForm = byId('casePlannerSubcaseForm');
const subcaseCard = subcaseForm?.closest('.case-planner-card');
const plannerBody = byId('casePlannerAllocationForm')?.closest('.case-planner-card-body');
if (subcaseCard && plannerBody) {
const subcases = document.createElement('details');
subcases.className = 'v5-planner-subcases mt-3';
const summary = document.createElement('summary');
summary.textContent = 'Vis undersagers planlægning';
subcases.append(summary);
const calendar = byId('casePlannerSubcaseWeek')?.closest('.case-planner-calendar');
if (calendar) subcases.append(calendar);
const list = byId('casePlannerSubcaseList');
if (list) subcases.append(list);
plannerBody.append(subcases);
subcaseCard.remove();
}
if (plannerBody) {
// The existing form still supplies defaults to the shared booking dialog.
byId('casePlannerAllocationForm').hidden = true;
const header = plannerBody.previousElementSibling;
header.querySelector('h5').textContent = 'Hvornår skal sagen løses?';
header.querySelector('.small').textContent = 'Klik på en dag i kalenderen for at planlægge arbejdet.';
const refresh = header.querySelector('button');
refresh.title = 'Opdatér planen';
refresh.setAttribute('aria-label', 'Opdatér planen');
const add = document.createElement('button');
add.type = 'button';
add.className = 'btn btn-primary v5-plan-add';
add.textContent = '+ Planlæg tid';
add.addEventListener('click', () => {
const today = new Date();
const localDate = new Date(today.getTime() - today.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
window.openEmbeddedPlannerSlot(localDate);
});
header.insertBefore(add, refresh);
const metrics = byId('casePlannerEstimated').closest('.row');
const overview = document.createElement('details');
overview.className = 'v5-planner-overview mt-3';
const overviewTitle = document.createElement('summary');
overviewTitle.textContent = 'Vis forventet og planlagt tid';
overview.append(overviewTitle, metrics);
plannerBody.append(overview);
const calendar = byId('casePlannerWeek').closest('.case-planner-calendar');
calendar.querySelector('.case-planner-calendar-toolbar > span').remove();
calendar.querySelector('button:nth-child(2)').textContent = 'Denne uge';
const listTitle = document.createElement('h6');
listTitle.className = 'mt-4 mb-0';
listTitle.textContent = 'Aftaler på sagen';
byId('casePlannerAllocationList').before(listTitle);
byId('casePlannerSlotModalTitle').textContent = 'Planlæg tid på sagen';
byId('casePlannerSlotSaveBtn').textContent = 'Gem aftale';
const duration = byId('casePlannerSlotMinutes');
duration.closest('.col-12').querySelector('label').textContent = 'Hvor lang tid?';
const presets = document.createElement('div');
presets.className = 'd-flex gap-2 mt-2';
for (const [minutes, label] of [[30, '30 min'], [60, '1 time'], [120, '2 timer']]) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-outline-secondary';
button.textContent = label;
button.addEventListener('click', () => { duration.value = String(minutes); });
presets.append(button);
}
duration.closest('.col-12').append(presets);
const note = byId('casePlannerSlotNote').closest('.col-12');
const noteDetails = document.createElement('details');
noteDetails.className = 'col-12';
const noteTitle = document.createElement('summary');
noteTitle.textContent = 'Tilføj en note (valgfrit)';
note.before(noteDetails);
noteDetails.append(noteTitle, note);
const modal = byId('casePlannerSlotModal');
modal.querySelector('.alert').textContent = 'Aftalen reserverer tid i kalenderen. Udført arbejde registreres separat.';
modal.querySelector('.modal-body > .small').textContent = 'Du får besked, hvis tiden overlapper en anden aftale.';
}
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();
})();