/* 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')];
// 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 = `${label}`;
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 = ``;
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 = '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);
});
// 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 = '';
}
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();
})();