bmc_hub/static/js/case-v5.js

427 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* V5 composes the established V4 controls without copying their implementations. */
(() => {
'use strict';
const config = JSON.parse(document.getElementById('caseV5Data').textContent);
const byId = id => document.getElementById(id);
const shell = document.querySelector('.case-detail-page-shell');
shell.classList.add('case-v5');
const billingHost = document.createElement('div');
billingHost.className='col-12';
billingHost.innerHTML='<label for="v5QuickBilling" class="form-label small">Afregning</label><select id="v5QuickBilling" class="form-select form-select-sm" disabled><option>Indlæser afregning…</option></select><div id="v5BillingHint" class="form-text">Kladder kontrolleres af en chef før afregning.</div>';
byId('commentQuickTimeRow').querySelector('.row').append(billingHost);
const compactBillingHost=billingHost.cloneNode(true);
compactBillingHost.querySelector('label').htmlFor='v5CompactBilling';
compactBillingHost.querySelector('select').id='v5CompactBilling';
compactBillingHost.querySelector('.form-text').removeAttribute('id');
byId('caseQuickTimeDraftForm').querySelector('.row').insertBefore(compactBillingHost,byId('caseQuickTimeSaveButton').parentElement);
byId('caseQuickTimeSaveButton').innerHTML='<i class="bi bi-plus-lg me-1"></i>Gem tid';
async function loadBillingOptions() {
const quick=byId('v5QuickBilling'), modal=byId('time_billing_method'), compact=byId('v5CompactBilling');
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');
if(data.workflow_active === true) {
for(const select of [quick,modal,compact].filter(Boolean)) {
select.replaceChildren(new Option('Afregning vælges automatisk','draft'));
select.value='draft'; select.disabled=true; select.hidden=true;
select.dataset.billingReady='true'; select.dataset.workflowActive='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='Afregning vælges automatisk';
if(modal){const hint=document.createElement('div');hint.className='form-text';hint.textContent='Afregning vælges automatisk';modal.after(hint);}
return;
}
const rate=Number(data.hourly_rate).toLocaleString('da-DK',{minimumFractionDigits:2,maximumFractionDigits:2});
const choices=[['draft','Kladde – skal kontrolleres af en chef'],['operations','Drift – chef vælger afregning'],['development','Udvikling – chef vælger afregning']];
if(data.has_customer)choices.push(['invoice',`Faktura – ${rate} kr./time`]);
(data.prepaid_cards||[]).forEach(card=>choices.push([`card_${card.id}`,`Klippekort #${card.card_number || card.id} · ${Number(card.remaining_hours).toLocaleString('da-DK')} timer tilbage · ${data.customer_name}`]));
(data.agreements||[]).forEach(agreement=>choices.push([`fpa_${agreement.id}`,`Fastpris #${agreement.agreement_number || agreement.id} · ${Number(agreement.monthly_hours).toLocaleString('da-DK')} timer/måned · ${data.customer_name}`]));
for(const select of [quick,modal,compact].filter(Boolean)) {
select.replaceChildren(...choices.map(([value,label])=>new Option(label,value)));
select.value='draft';select.disabled=false;select.dataset.billingReady='true';
}
if(modal){ const hint=document.createElement('div');hint.className='form-text';hint.textContent=`Kundens timepris: ${rate} kr. Kladder kontrolleres af en chef.`;modal.after(hint); }
byId('v5BillingHint').textContent=`Kundens timepris: ${rate} kr./time. Kladder kontrolleres af en chef. Kun aktive klippekort og fastprisaftaler til sagens firma kan vælges.`;
} catch(error){ byId('v5BillingHint').textContent=error.message; if(modal){modal.disabled=true;modal.dataset.billingReady='false';} }
}
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();
})();