- 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.
553 lines
33 KiB
JavaScript
553 lines
33 KiB
JavaScript
/* 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();
|
||
})();
|