bmc_hub/static/js/case-v5.js

425 lines
24 KiB
JavaScript
Raw Normal View History

/* 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();
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);
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 facts = document.createElement('aside');
facts.className = 'v5-case-facts';
facts.setAttribute('aria-label', 'Kunde og sagsoplysninger');
const center = document.createElement('main');
const modules = document.createElement('aside');
modules.className = 'v5-case-modules';
modules.setAttribute('aria-label', 'Todo, filer og moduler');
const tabs = byId('caseTabs');
tabs.before(board);
board.append(facts, center, modules);
const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')];
[3, 1, 0].forEach(index => { if (widgets[index]) facts.append(widgets[index]); });
const factsDisclosure = document.createElement('details');
factsDisclosure.className = 'v5-facts-disclosure';
const factsSummary = document.createElement('summary');
factsSummary.textContent = 'Kunde, kontakt og sagsoplysninger';
const factsContent = document.createElement('div');
factsContent.className = 'v5-facts-content';
factsContent.append(...facts.children);
factsDisclosure.append(factsSummary, factsContent);
facts.append(factsDisclosure);
const compactScreen = window.matchMedia('(max-width: 1400px)');
function adaptFacts(event) { factsDisclosure.open = !event.matches; }
adaptFacts(compactScreen);
compactScreen.addEventListener('change', adaptFacts);
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);
const rightGrid = right.querySelector('.right-modules-grid');
['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"]'));
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 && groupId) 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 renderUsers() {
const items = [{id: null, name: 'Kun gruppen'}, ...members.map(member => ({id: member.user_id, name: member.display_name}))];
renderPicker(byId('v5UserOptions'), items, userId, id => { userId = id; renderUsers(); }, byId('v5UserSearch').value);
renderActions();
}
async function loadMembers() {
const requestedGroupId = groupId;
const sequence = ++memberRequest;
membersLoading = true;
byId('v5UserSearch').disabled = true;
byId('v5UserOptions').textContent = 'Henter medarbejdere…';
try {
let data = memberCache.get(requestedGroupId);
if (!data) {
const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${requestedGroupId}`, {credentials: 'include'});
if (!response.ok) throw new Error('Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.');
data = await response.json();
memberCache.set(requestedGroupId, data);
}
if (sequence !== memberRequest) return;
members = data.members || [];
if (!members.some(member => member.user_id === userId)) userId = null;
byId('v5UserSearch').disabled = false;
byId('v5UserSearch').placeholder = 'Find medarbejder…';
renderUsers(); feedback('');
} catch (error) {
if (sequence === memberRequest) { byId('v5UserOptions').textContent = ''; feedback(error.message, true); }
} finally { if (sequence === memberRequest) membersLoading = false; }
}
byId('v5GroupSearch').addEventListener('input', renderGroups);
byId('v5UserSearch').addEventListener('input', renderUsers);
byId('commentQuickTimeMinutes').addEventListener('input', renderActions);
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();
})();