270 lines
14 KiB
JavaScript
270 lines
14 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');
|
|||
|
|
shell.prepend(byId('caseV5Tools').content.cloneNode(true));
|
|||
|
|
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 relationer');
|
|||
|
|
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, 2].forEach(index => { if (widgets[index]) facts.append(widgets[index]); });
|
|||
|
|
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'));
|
|||
|
|
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', 'relations', 'hardware', 'time'].reverse().forEach(key => {
|
|||
|
|
const card = shell.querySelector(`.right-module-card[data-module="${key}"]`);
|
|||
|
|
if (card) rightGrid.prepend(card);
|
|||
|
|
});
|
|||
|
|
// 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;
|
|||
|
|
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';
|
|||
|
|
}
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
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 sequence = ++memberRequest;
|
|||
|
|
membersLoading = true;
|
|||
|
|
byId('v5UserSearch').disabled = true;
|
|||
|
|
byId('v5UserOptions').textContent = 'Henter medarbejdere…';
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${groupId}`, {credentials: 'include'});
|
|||
|
|
if (!response.ok) throw new Error('Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.');
|
|||
|
|
const data = await response.json();
|
|||
|
|
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);
|
|||
|
|
function lock(locked) {
|
|||
|
|
form.querySelectorAll('input, textarea, select, button').forEach(el => { el.disabled = locked; });
|
|||
|
|
// 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.removeAttribute('onsubmit');
|
|||
|
|
form.addEventListener('submit', async event => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
if (busy) 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') && (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.');
|
|||
|
|
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();
|
|||
|
|
})();
|