- Implemented subscription creation, updating, and rendering in script_9.js. - Added functions for handling subscription line items, product selection, and total calculations. - Integrated AnyDesk API for session management in test_anydesk.py. - Created REST client test requests for API endpoints in api.http. - Developed a script to check ESET machine status and save details in tmp_check_eset_machine.py.
2261 lines
100 KiB
JavaScript
2261 lines
100 KiB
JavaScript
|
|
let currentSearchType = null;
|
|
let searchDebounceIds = null;
|
|
const caseIds = {{ case.id }};
|
|
const currentCaseTitle = {{ (case.titel or '') | tojson }};
|
|
let caseAddPanelInitialized = false;
|
|
let caseAddActiveAction = null;
|
|
let caseAddOriginalShowRelModal = null;
|
|
const CASE_ADD_ACTIONS = [
|
|
{ action: 'assign', label: 'Tildel sag', icon: 'bi-person-check', moduleKey: null, relFn: 'openRelAssignModal' },
|
|
{ action: 'time', label: 'Tidregistrering', icon: 'bi-clock', moduleKey: 'time', relFn: 'openRelTimeModal' },
|
|
{ action: 'note', label: 'Kommentar', icon: 'bi-chat-left-text', moduleKey: 'solution', relFn: 'openRelNoteModal' },
|
|
{ action: 'reminder', label: 'Pamindelse', icon: 'bi-bell', moduleKey: 'reminders', relFn: 'openRelReminderModal' },
|
|
{ action: 'pipeline', label: 'Salgspipeline', icon: 'bi-graph-up-arrow', moduleKey: 'pipeline', relFn: 'openRelPipelineModal' },
|
|
{ action: 'files', label: 'Filer', icon: 'bi-paperclip', moduleKey: 'files', relFn: 'openRelFilesModal' },
|
|
{ action: 'hardware', label: 'Hardware', icon: 'bi-cpu', moduleKey: 'hardware', relFn: 'openRelHardwareModal' },
|
|
{ action: 'todo', label: 'Opgave', icon: 'bi-check2-square', moduleKey: 'todo-steps', relFn: 'openRelTodoModal' },
|
|
{ action: 'solution', label: 'Losning', icon: 'bi-lightbulb', moduleKey: 'solution', relFn: 'openRelSolutionModal' },
|
|
{ action: 'sales', label: 'Varekob og salg', icon: 'bi-bag', moduleKey: 'sales', relFn: 'openRelSalesModal' },
|
|
{ action: 'subscription', label: 'Abonnement', icon: 'bi-arrow-repeat', moduleKey: 'subscription', relFn: 'openRelSubscriptionModal' },
|
|
{ action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
|
|
];
|
|
|
|
async function openCaseModuleAddPanel() {
|
|
if (typeof loadModulePrefs === 'function') {
|
|
await loadModulePrefs();
|
|
}
|
|
|
|
const panel = document.getElementById('caseAddSidePanel');
|
|
const backdrop = document.getElementById('caseAddSideBackdrop');
|
|
if (!panel || !backdrop) return;
|
|
|
|
backdrop.classList.add('open');
|
|
panel.classList.add('open');
|
|
panel.setAttribute('aria-hidden', 'false');
|
|
|
|
if (!caseAddOriginalShowRelModal && typeof window._showRelModal === 'function') {
|
|
caseAddOriginalShowRelModal = window._showRelModal;
|
|
}
|
|
if (typeof caseAddOriginalShowRelModal === 'function') {
|
|
window._showRelModal = renderCaseAddWorkspaceModal;
|
|
}
|
|
|
|
renderCaseAddActionList(caseAddActiveAction);
|
|
caseAddPanelInitialized = true;
|
|
}
|
|
|
|
function closeCaseModuleAddPanel() {
|
|
const panel = document.getElementById('caseAddSidePanel');
|
|
const backdrop = document.getElementById('caseAddSideBackdrop');
|
|
if (!panel || !backdrop) return;
|
|
|
|
panel.classList.remove('open');
|
|
panel.setAttribute('aria-hidden', 'true');
|
|
backdrop.classList.remove('open');
|
|
|
|
if (typeof caseAddOriginalShowRelModal === 'function') {
|
|
window._showRelModal = caseAddOriginalShowRelModal;
|
|
}
|
|
}
|
|
|
|
function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) {
|
|
const workspace = document.getElementById('caseAddSideWorkspace');
|
|
if (!workspace) return;
|
|
|
|
workspace.innerHTML = `
|
|
<div id="relQaModalEl" class="d-flex flex-column gap-2">
|
|
<div class="section-title">${title}</div>
|
|
<div id="relQaModalBody">${bodyHtml}</div>
|
|
<div id="relQaModalFooter" class="d-flex justify-content-end gap-2 border-top pt-2">
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeCaseModuleAddPanel()">Luk</button>
|
|
${footerBtns || ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
workspace.querySelectorAll('form').forEach((formEl) => {
|
|
formEl.addEventListener('submit', (evt) => evt.preventDefault());
|
|
});
|
|
|
|
workspace.querySelectorAll('#relQaModalFooter button').forEach((btnEl) => {
|
|
if (!btnEl.getAttribute('type')) {
|
|
btnEl.setAttribute('type', 'button');
|
|
}
|
|
});
|
|
}
|
|
|
|
function _isCaseAddModuleEnabled(actionConfig) {
|
|
if (!actionConfig?.moduleKey) return true;
|
|
if (actionConfig.moduleKey === 'time') return true;
|
|
return modulePrefs[actionConfig.moduleKey] !== false;
|
|
}
|
|
|
|
function _renderCaseAddModuleToggle(actionConfig) {
|
|
if (!actionConfig?.moduleKey) {
|
|
return '<span class="d-inline-block" style="width:1rem; height:1rem;"></span>';
|
|
}
|
|
|
|
const isTimeModule = actionConfig.moduleKey === 'time';
|
|
const isChecked = _isCaseAddModuleEnabled(actionConfig);
|
|
return `<input type="checkbox" class="case-add-module-toggle" ${isChecked ? 'checked' : ''} ${isTimeModule ? 'disabled' : ''} onchange="toggleModulePref('${actionConfig.moduleKey}', this.checked)">`;
|
|
}
|
|
|
|
function renderCaseAddActionList(preferredAction = null) {
|
|
const listEl = document.getElementById('caseAddModuleList');
|
|
if (!listEl) return;
|
|
|
|
const actions = CASE_ADD_ACTIONS;
|
|
if (!actions.length) {
|
|
listEl.innerHTML = '<div class="text-muted small">Ingen aktive moduler fundet.</div>';
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = actions.map((cfg) => `
|
|
<div class="case-add-module-item">
|
|
<button type="button" class="case-add-module-btn" id="caseAddAction_${cfg.action}" onclick="openCaseAddAction('${cfg.action}')">
|
|
<i class="bi ${cfg.icon} me-1"></i>${cfg.label}
|
|
</button>
|
|
${_renderCaseAddModuleToggle(cfg)}
|
|
</div>
|
|
`).join('');
|
|
|
|
const fallbackAction = actions[0]?.action || null;
|
|
const nextAction = actions.some((cfg) => cfg.action === preferredAction) ? preferredAction : fallbackAction;
|
|
if (nextAction) {
|
|
openCaseAddAction(nextAction);
|
|
}
|
|
}
|
|
|
|
async function openCaseAddAction(actionName) {
|
|
document.querySelectorAll('.case-add-module-btn').forEach((btn) => btn.classList.remove('active'));
|
|
document.getElementById(`caseAddAction_${actionName}`)?.classList.add('active');
|
|
caseAddActiveAction = actionName;
|
|
|
|
const action = CASE_ADD_ACTIONS.find((cfg) => cfg.action === actionName);
|
|
const workspace = document.getElementById('caseAddSideWorkspace');
|
|
if (!action || !workspace) return;
|
|
|
|
workspace.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlaeser formular...</div>';
|
|
|
|
const relFn = window[action.relFn];
|
|
if (typeof relFn !== 'function') {
|
|
workspace.innerHTML = '<div class="text-danger small">Modulformular er ikke tilgaengelig endnu.</div>';
|
|
return;
|
|
}
|
|
|
|
const existingRelQaEl = document.getElementById('relQaModalEl');
|
|
if (existingRelQaEl && !workspace.contains(existingRelQaEl)) {
|
|
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
|
|
if (existingModalInstance) {
|
|
existingModalInstance.hide();
|
|
}
|
|
existingRelQaEl.remove();
|
|
}
|
|
|
|
try {
|
|
await Promise.resolve(relFn(caseIds, currentCaseTitle));
|
|
} catch (error) {
|
|
console.error('Could not load module add form', error);
|
|
workspace.innerHTML = '<div class="text-danger small">Kunne ikke indlaese formularen.</div>';
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
const panel = document.getElementById('caseAddSidePanel');
|
|
if (panel && panel.classList.contains('open')) {
|
|
closeCaseModuleAddPanel();
|
|
}
|
|
}
|
|
});
|
|
|
|
function openSearchModal(type) {
|
|
currentSearchType = type;
|
|
const titles = {
|
|
'hardware': 'Tilføj Hardware',
|
|
'location': 'Tilføj Lokation',
|
|
'contact': 'Tilføj Kontakt',
|
|
'customer': 'Tilføj Kunde'
|
|
};
|
|
document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg';
|
|
document.getElementById('entitySearchInput').value = '';
|
|
document.getElementById('entitySearchResults').innerHTML = '';
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('entitySearchModal'));
|
|
modal.show();
|
|
|
|
setTimeout(() => document.getElementById('entitySearchInput').focus(), 500);
|
|
}
|
|
|
|
document.getElementById('entitySearchInput').addEventListener('input', function(e) {
|
|
clearTimeout(searchDebounceIds);
|
|
const query = e.target.value.trim();
|
|
if (query.length < 2) {
|
|
document.getElementById('entitySearchResults').innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
searchDebounceIds = setTimeout(() => performSearch(query), 300);
|
|
});
|
|
|
|
async function performSearch(query) {
|
|
document.getElementById('entitySearchSpinner').classList.remove('d-none');
|
|
document.getElementById('entitySearchResults').classList.add('d-none');
|
|
|
|
try {
|
|
let url = '';
|
|
if (currentSearchType === 'hardware') url = `/api/v1/search/hardware?q=${encodeURIComponent(query)}`;
|
|
else if (currentSearchType === 'location') url = `/api/v1/search/locations?q=${encodeURIComponent(query)}`;
|
|
else if (currentSearchType === 'contact') url = `/api/v1/search/contacts?q=${encodeURIComponent(query)}`;
|
|
else if (currentSearchType === 'customer') url = `/api/v1/search/customers?q=${encodeURIComponent(query)}`;
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error('Search failed');
|
|
const results = await res.json();
|
|
renderResults(results);
|
|
} catch (e) {
|
|
console.error(e);
|
|
document.getElementById('entitySearchResults').innerHTML = '<div class="text-danger text-center p-3">Fejl ved søgning</div>';
|
|
} finally {
|
|
document.getElementById('entitySearchSpinner').classList.add('d-none');
|
|
document.getElementById('entitySearchResults').classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
function renderResults(results) {
|
|
const container = document.getElementById('entitySearchResults');
|
|
if (results.length === 0) {
|
|
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = results.map(item => {
|
|
let title = '', subtitle = '', icon = '', id = item.id;
|
|
|
|
if (currentSearchType === 'hardware') {
|
|
title = `${item.brand} ${item.model}`;
|
|
subtitle = `SN: ${item.serial_number}`;
|
|
icon = 'bi-laptop';
|
|
} else if (currentSearchType === 'location') {
|
|
title = item.name;
|
|
subtitle = `${item.address_street || ''} ${item.address_city || ''}`;
|
|
icon = 'bi-geo-alt';
|
|
} else if (currentSearchType === 'contact') {
|
|
title = `${item.first_name} ${item.last_name}`;
|
|
subtitle = item.email;
|
|
icon = 'bi-person';
|
|
} else if (currentSearchType === 'customer') {
|
|
title = item.name;
|
|
subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`;
|
|
icon = 'bi-building';
|
|
}
|
|
|
|
return `
|
|
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center" onclick="addEntity(${id})">
|
|
<div class="me-3 fs-4 text-muted"><i class="bi ${icon}"></i></div>
|
|
<div>
|
|
<div class="fw-bold">${title}</div>
|
|
<small class="text-muted">${subtitle}</small>
|
|
</div>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function addEntity(id) {
|
|
let url = '', body = {};
|
|
|
|
if (currentSearchType === 'hardware') {
|
|
url = `/api/v1/sag/${caseIds}/hardware`;
|
|
body = { hardware_id: id };
|
|
} else if (currentSearchType === 'location') {
|
|
url = `/api/v1/sag/${caseIds}/locations`;
|
|
body = { location_id: id };
|
|
} else if (currentSearchType === 'contact') {
|
|
url = `/api/v1/sag/${caseIds}/contacts`;
|
|
body = { contact_id: id };
|
|
} else if (currentSearchType === 'customer') {
|
|
url = `/api/v1/sag/${caseIds}/customers`;
|
|
body = { customer_id: id };
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
alert("Fejl: " + (err.detail || 'Kunne ikke tilføje'));
|
|
return;
|
|
}
|
|
|
|
bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide();
|
|
window.location.reload();
|
|
} catch (e) {
|
|
alert("Fejl: " + e.message);
|
|
}
|
|
}
|
|
|
|
async function removeContact(caseId, contactId) {
|
|
if(!confirm("Fjern denne kontakt fra sagen?")) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, { method: 'DELETE' });
|
|
if (res.ok) window.location.reload();
|
|
else alert("Fejl ved sletning");
|
|
} catch(e) { alert("Fejl: " + e.message); }
|
|
}
|
|
|
|
function openContactRoleModal(contactId, contactName, role, isPrimary) {
|
|
document.getElementById('contactRoleContactId').value = contactId;
|
|
document.getElementById('contactRoleName').textContent = contactName || '-';
|
|
document.getElementById('contactRoleInput').value = role || '';
|
|
document.getElementById('contactRolePrimary').checked = !!isPrimary;
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('contactRoleModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function saveContactRole() {
|
|
const contactId = document.getElementById('contactRoleContactId').value;
|
|
const role = document.getElementById('contactRoleInput').value.trim();
|
|
const isPrimary = document.getElementById('contactRolePrimary').checked;
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/contacts/${contactId}`, {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ role, is_primary: isPrimary })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Kunne ikke opdatere kontakt');
|
|
}
|
|
|
|
bootstrap.Modal.getInstance(document.getElementById('contactRoleModal')).hide();
|
|
window.location.reload();
|
|
} catch (e) {
|
|
alert('Fejl: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function removeCustomer(caseId, customerId) {
|
|
if(!confirm("Fjern denne kunde fra sagen?")) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, { method: 'DELETE' });
|
|
if (res.ok) window.location.reload();
|
|
else alert("Fejl ved sletning");
|
|
} catch(e) { alert("Fejl: " + e.message); }
|
|
}
|
|
|
|
async function updateDeferredUntil(value) {
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ deferred_until: value || null })
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Kunne ikke opdatere');
|
|
}
|
|
window.location.reload();
|
|
} catch (e) {
|
|
alert('Fejl: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function updateDeadline(value) {
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ deadline: value || null })
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Kunne ikke opdatere deadline');
|
|
}
|
|
window.location.reload();
|
|
} catch (e) {
|
|
alert('Fejl: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function shiftDeadlineDays(days) {
|
|
const input = document.getElementById('deadlineInput');
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|
base.setDate(base.getDate() + days);
|
|
input.value = base.toISOString().slice(0, 10);
|
|
updateDeadline(input.value);
|
|
}
|
|
|
|
function shiftDeadlineMonths(months) {
|
|
const input = document.getElementById('deadlineInput');
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|
base.setMonth(base.getMonth() + months);
|
|
input.value = base.toISOString().slice(0, 10);
|
|
updateDeadline(input.value);
|
|
}
|
|
|
|
function openDeadlineModal() {
|
|
const modal = new bootstrap.Modal(document.getElementById('deadlineModal'));
|
|
modal.show();
|
|
}
|
|
|
|
function saveDeadlineAll() {
|
|
const input = document.getElementById('deadlineInput');
|
|
updateDeadline(input.value || null);
|
|
}
|
|
|
|
function clearDeadlineAll() {
|
|
const input = document.getElementById('deadlineInput');
|
|
input.value = '';
|
|
updateDeadline(null);
|
|
}
|
|
|
|
function setDeferredFromInput() {
|
|
const input = document.getElementById('deferredUntilInput');
|
|
updateDeferredUntil(input.value || null);
|
|
}
|
|
|
|
function shiftDeferredDays(days) {
|
|
const input = document.getElementById('deferredUntilInput');
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|
base.setDate(base.getDate() + days);
|
|
input.value = base.toISOString().slice(0, 10);
|
|
updateDeferredUntil(input.value);
|
|
}
|
|
|
|
function shiftDeferredMonths(months) {
|
|
const input = document.getElementById('deferredUntilInput');
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|
base.setMonth(base.getMonth() + months);
|
|
input.value = base.toISOString().slice(0, 10);
|
|
updateDeferredUntil(input.value);
|
|
}
|
|
|
|
function clearDeferredUntil() {
|
|
const input = document.getElementById('deferredUntilInput');
|
|
input.value = '';
|
|
updateDeferredUntil(null);
|
|
}
|
|
|
|
function openDeferredModal() {
|
|
const modal = new bootstrap.Modal(document.getElementById('deferredModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function updateDeferredCaseAndStatus(caseId, status) {
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
deferred_until_case_id: caseId ? parseInt(caseId, 10) : null,
|
|
deferred_until_status: status || null
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Kunne ikke opdatere');
|
|
}
|
|
window.location.reload();
|
|
} catch (e) {
|
|
alert('Fejl: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function setDeferredCaseFromInputs() {
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
|
|
}
|
|
|
|
function clearDeferredCase() {
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|
caseSelect.value = '';
|
|
statusSelect.value = '';
|
|
updateDeferredCaseAndStatus(null, null);
|
|
}
|
|
|
|
function saveDeferredAll() {
|
|
const input = document.getElementById('deferredUntilInput');
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|
updateDeferredUntil(input.value || null);
|
|
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
|
|
}
|
|
|
|
function clearDeferredAll() {
|
|
const input = document.getElementById('deferredUntilInput');
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|
input.value = '';
|
|
caseSelect.value = '';
|
|
statusSelect.value = '';
|
|
updateDeferredUntil(null);
|
|
updateDeferredCaseAndStatus(null, null);
|
|
}
|
|
|
|
function togglePipelineEdit(forceEdit = null) {
|
|
const view = document.getElementById('pipelineViewMode');
|
|
const edit = document.getElementById('pipelineEditMode');
|
|
const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit;
|
|
|
|
if (shouldEdit) {
|
|
view.classList.add('d-none');
|
|
edit.classList.remove('d-none');
|
|
} else {
|
|
view.classList.remove('d-none');
|
|
edit.classList.add('d-none');
|
|
}
|
|
|
|
if (shouldEdit) {
|
|
ensurePipelineStagesLoaded();
|
|
}
|
|
}
|
|
|
|
async function ensurePipelineStagesLoaded() {
|
|
const select = document.getElementById('pipelineStageSelect');
|
|
if (!select) return;
|
|
|
|
if (select.options.length > 1) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
|
|
if (!response.ok) return;
|
|
|
|
const stages = await response.json();
|
|
if (!Array.isArray(stages) || stages.length === 0) return;
|
|
|
|
const existingValue = select.value || '';
|
|
select.innerHTML = '<option value="">Ikke sat</option>' +
|
|
stages.map((stage) => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
|
if (existingValue) {
|
|
select.value = existingValue;
|
|
}
|
|
} catch (error) {
|
|
console.error('Could not load pipeline stages', error);
|
|
}
|
|
}
|
|
|
|
async function saveCaseType(newType, newLabel, newIcon, newColor) {
|
|
// Update UI immediately for snappy feel
|
|
const btn = document.getElementById('caseTypeDropdownBtn');
|
|
const lbl = document.getElementById('caseTypeLabel');
|
|
const ico = document.getElementById('caseTypeIcon');
|
|
if (btn) btn.style.setProperty('--tcolor', newColor);
|
|
if (lbl) lbl.textContent = newLabel;
|
|
if (ico) { ico.className = 'bi ' + newIcon; }
|
|
|
|
try {
|
|
const resp = await fetch(`/api/v1/sag/${caseId}`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: newType })
|
|
});
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
// Reload to re-render template vars (color accent on ID chip etc.)
|
|
location.reload();
|
|
} catch (e) {
|
|
console.error('saveCaseType error', e);
|
|
showToast('Kunne ikke gemme sagstype', 'danger');
|
|
}
|
|
}
|
|
|
|
async function saveCaseStatusFromTopbar() {
|
|
const select = document.getElementById('topbarStatusSelect');
|
|
if (!select) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: select.value || 'åben' })
|
|
});
|
|
|
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
location.reload();
|
|
} catch (e) {
|
|
console.error('saveCaseStatusFromTopbar error', e);
|
|
showToast('Kunne ikke gemme status', 'danger');
|
|
}
|
|
}
|
|
|
|
async function hydrateTopbarStatusOptions() {
|
|
const select = document.getElementById('topbarStatusSelect');
|
|
if (!select) return;
|
|
|
|
const initialValue = String(select.value || '').trim();
|
|
const known = new Map();
|
|
|
|
const addStatus = (raw) => {
|
|
const value = String(raw || '').trim();
|
|
if (!value) return;
|
|
const key = value.toLowerCase();
|
|
if (!known.has(key)) {
|
|
known.set(key, value);
|
|
}
|
|
};
|
|
|
|
Array.from(select.options || []).forEach((opt) => addStatus(opt.value));
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/sag?include_deferred=true', { credentials: 'include' });
|
|
if (response.ok) {
|
|
const cases = await response.json();
|
|
(Array.isArray(cases) ? cases : []).forEach((c) => addStatus(c?.status));
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not hydrate status options from cases API', error);
|
|
}
|
|
|
|
['åben', 'under behandling', 'afventer', 'løst', 'lukket'].forEach(addStatus);
|
|
addStatus(initialValue);
|
|
|
|
const sortedValues = Array.from(known.values()).sort((a, b) =>
|
|
a.localeCompare(b, 'da', { sensitivity: 'base' })
|
|
);
|
|
|
|
select.innerHTML = sortedValues.map((value) => {
|
|
const selected = initialValue && value.toLowerCase() === initialValue.toLowerCase();
|
|
return `<option value="${value}" ${selected ? 'selected' : ''}>${value.charAt(0).toUpperCase()}${value.slice(1)}</option>`;
|
|
}).join('');
|
|
|
|
if (initialValue) {
|
|
select.value = Array.from(known.values()).find((v) => v.toLowerCase() === initialValue.toLowerCase()) || initialValue;
|
|
}
|
|
}
|
|
|
|
function saveCaseTypeFromTopbar() {
|
|
const select = document.getElementById('topbarTypeSelect');
|
|
if (!select) return;
|
|
|
|
const typeMeta = {
|
|
ticket: { label: 'Ticket', icon: 'bi-ticket-perforated', color: '#6366f1' },
|
|
pipeline: { label: 'Pipeline', icon: 'bi-graph-up-arrow', color: '#0ea5e9' },
|
|
opgave: { label: 'Opgave', icon: 'bi-puzzle', color: '#f59e0b' },
|
|
ordre: { label: 'Ordre', icon: 'bi-receipt', color: '#10b981' },
|
|
projekt: { label: 'Projekt', icon: 'bi-folder2-open', color: '#8b5cf6' },
|
|
service: { label: 'Service', icon: 'bi-tools', color: '#ef4444' }
|
|
};
|
|
|
|
const nextType = (select.value || 'ticket').toLowerCase();
|
|
const meta = typeMeta[nextType] || typeMeta.ticket;
|
|
saveCaseType(nextType, meta.label, meta.icon, meta.color);
|
|
}
|
|
|
|
async function saveCasePriorityFromTopbar() {
|
|
const select = document.getElementById('topbarPrioritySelect');
|
|
if (!select) return;
|
|
|
|
try {
|
|
const resp = await fetch(`/api/v1/sag/${caseId}`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ priority: (select.value || 'normal').toLowerCase() })
|
|
});
|
|
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
location.reload();
|
|
} catch (e) {
|
|
console.error('saveCasePriorityFromTopbar error', e);
|
|
showToast('Kunne ikke gemme prioritet', 'danger');
|
|
}
|
|
}
|
|
|
|
async function saveCaseStartDateFromTopbar() {
|
|
const input = document.getElementById('topbarStartDateInput');
|
|
if (!input) return;
|
|
|
|
try {
|
|
const resp = await fetch(`/api/v1/sag/${caseId}`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ start_date: input.value || null })
|
|
});
|
|
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
location.reload();
|
|
} catch (e) {
|
|
console.error('saveCaseStartDateFromTopbar error', e);
|
|
showToast('Kunne ikke gemme startdato', 'danger');
|
|
}
|
|
}
|
|
|
|
function clearCaseStartDateFromTopbar() {
|
|
const input = document.getElementById('topbarStartDateInput');
|
|
if (!input) return;
|
|
input.value = '';
|
|
saveCaseStartDateFromTopbar();
|
|
}
|
|
|
|
async function saveAssignmentFromTabsBar() {
|
|
const topUser = document.getElementById('tabsAssignmentUserSelect');
|
|
const topGroup = document.getElementById('tabsAssignmentGroupSelect');
|
|
const legacyUser = document.getElementById('assignmentUserSelect');
|
|
const legacyGroup = document.getElementById('assignmentGroupSelect');
|
|
|
|
if (legacyUser && topUser) {
|
|
legacyUser.value = topUser.value;
|
|
}
|
|
if (legacyGroup && topGroup) {
|
|
legacyGroup.value = topGroup.value;
|
|
}
|
|
|
|
await saveAssignment();
|
|
}
|
|
|
|
async function saveAssignment() {
|
|
const statusEl = document.getElementById('assignmentStatus');
|
|
const userValue = document.getElementById('assignmentUserSelect')?.value || '';
|
|
const groupValue = document.getElementById('assignmentGroupSelect')?.value || '';
|
|
|
|
const payload = {
|
|
ansvarlig_bruger_id: userValue ? parseInt(userValue, 10) : null,
|
|
assigned_group_id: groupValue ? parseInt(groupValue, 10) : null
|
|
};
|
|
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Gemmer...';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let message = 'Kunne ikke gemme tildeling';
|
|
try {
|
|
const data = await response.json();
|
|
message = data.detail || message;
|
|
} catch (err) {
|
|
// Keep default message
|
|
}
|
|
if (statusEl) {
|
|
statusEl.textContent = `❌ ${message}`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (statusEl) {
|
|
statusEl.textContent = '✅ Tildeling gemt';
|
|
}
|
|
|
|
const topUser = document.getElementById('tabsAssignmentUserSelect');
|
|
const topGroup = document.getElementById('tabsAssignmentGroupSelect');
|
|
if (topUser) {
|
|
topUser.value = userValue;
|
|
}
|
|
if (topGroup) {
|
|
topGroup.value = groupValue;
|
|
}
|
|
} catch (err) {
|
|
if (statusEl) {
|
|
statusEl.textContent = `❌ ${err.message}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function savePipeline() {
|
|
const stageValue = document.getElementById('pipelineStageSelect').value;
|
|
const probabilityValue = document.getElementById('pipelineProbabilityInput').value;
|
|
const amountValue = document.getElementById('pipelineAmountInput').value;
|
|
const descriptionValue = document.getElementById('pipelineDescriptionInput').value;
|
|
|
|
const payload = {
|
|
stage_id: stageValue ? parseInt(stageValue, 10) : null,
|
|
probability: probabilityValue === '' ? null : parseInt(probabilityValue, 10),
|
|
amount: amountValue === '' ? null : parseFloat(amountValue),
|
|
description: descriptionValue === '' ? null : descriptionValue
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/sag/${caseId}/pipeline`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let message = 'Kunne ikke opdatere pipeline';
|
|
try {
|
|
const err = await response.json();
|
|
message = err.detail || err.message || message;
|
|
} catch (_e) {
|
|
const text = await response.text();
|
|
if (text) message = text;
|
|
}
|
|
throw new Error(`${message} (HTTP ${response.status})`);
|
|
}
|
|
|
|
window.location.reload();
|
|
} catch (error) {
|
|
alert(`Fejl: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// VIEW CONTROL (Tag-based)
|
|
// ==========================================
|
|
|
|
let modulePrefs = {};
|
|
let currentCaseView = 'Sag-detalje';
|
|
|
|
function moduleHasContent(el) {
|
|
const attr = el.getAttribute('data-has-content');
|
|
if (attr === 'true') return true;
|
|
if (attr === 'false') return false;
|
|
if (attr === 'unknown') return false;
|
|
|
|
if (el.querySelector('.person-card')) return true;
|
|
if (el.querySelector('.list-group-item')) return true;
|
|
return true;
|
|
}
|
|
|
|
function setModuleContentState(moduleKey, hasContent) {
|
|
const el = document.querySelector(`[data-module="${moduleKey}"]`);
|
|
if (!el) return;
|
|
el.setAttribute('data-has-content', hasContent ? 'true' : 'false');
|
|
applyViewLayout(currentCaseView);
|
|
}
|
|
|
|
function applyViewLayout(viewName) {
|
|
if (!viewName) return;
|
|
currentCaseView = viewName;
|
|
document.body.setAttribute('data-case-view', viewName);
|
|
|
|
const viewDefaults = {
|
|
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
|
|
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'],
|
|
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
|
|
};
|
|
|
|
const defaultsByCaseType = caseTypeModuleDefaults[caseTypeKey];
|
|
const standardModules = Array.isArray(defaultsByCaseType) && defaultsByCaseType.length > 0
|
|
? defaultsByCaseType
|
|
: (viewDefaults[viewName] || []);
|
|
const standardModuleSet = new Set(standardModules);
|
|
standardModuleSet.add('tags');
|
|
standardModuleSet.add('time');
|
|
|
|
document.querySelectorAll('[data-module]').forEach((el) => {
|
|
const moduleName = el.getAttribute('data-module');
|
|
const hasContent = moduleHasContent(el);
|
|
const isTimeModule = moduleName === 'time';
|
|
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
|
|
const pref = modulePrefs[moduleName];
|
|
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
|
|
|
// Helper til at skjule eller vise modulet og dets mb-3 indpakning
|
|
const setVisibility = (visible) => {
|
|
let wrapper = null;
|
|
if (el.parentElement) {
|
|
const isMB3 = el.parentElement.classList.contains('mb-3');
|
|
const isRowCol12 = el.parentElement.classList.contains('col-12') && el.parentElement.parentElement && el.parentElement.parentElement.classList.contains('row');
|
|
if (isMB3) wrapper = el.parentElement;
|
|
else if (isRowCol12) wrapper = el.parentElement.parentElement;
|
|
}
|
|
|
|
if (visible) {
|
|
el.classList.remove('d-none');
|
|
if (wrapper && wrapper.classList.contains('d-none')) {
|
|
wrapper.classList.remove('d-none');
|
|
}
|
|
if (tabButton && tabButton.classList.contains('d-none')) {
|
|
tabButton.classList.remove('d-none');
|
|
}
|
|
} else {
|
|
el.classList.add('d-none');
|
|
if (wrapper && !wrapper.classList.contains('d-none')) wrapper.classList.add('d-none');
|
|
if (tabButton && !tabButton.classList.contains('d-none')) tabButton.classList.add('d-none');
|
|
}
|
|
};
|
|
|
|
// Altid vis time (tid)
|
|
if (isTimeModule) {
|
|
setVisibility(true);
|
|
el.classList.remove('module-empty-compact');
|
|
return;
|
|
}
|
|
|
|
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
|
|
if (pref === false) {
|
|
setVisibility(false);
|
|
el.classList.remove('module-empty-compact');
|
|
return;
|
|
}
|
|
|
|
// HVIS specifik præference aktiverer den (brugervalg)
|
|
if (pref === true) {
|
|
setVisibility(true);
|
|
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty && !hasContent);
|
|
return;
|
|
}
|
|
|
|
// Default logic (ingen brugervalg) - har den content, så vis den
|
|
if (hasContent) {
|
|
setVisibility(true);
|
|
el.classList.remove('module-empty-compact');
|
|
return;
|
|
}
|
|
|
|
// Default logic - ingen content: se på layout defaults
|
|
if (standardModuleSet.has(moduleName)) {
|
|
setVisibility(true);
|
|
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty);
|
|
} else {
|
|
setVisibility(false);
|
|
el.classList.remove('module-empty-compact');
|
|
}
|
|
});
|
|
|
|
updateRightColumnVisibility();
|
|
updateInnerColumnVisibility();
|
|
}
|
|
|
|
function updateRightColumnVisibility() {
|
|
const rightColumn = document.getElementById('case-right-column');
|
|
const leftColumn = document.getElementById('case-left-column');
|
|
if (!rightColumn || !leftColumn) return;
|
|
|
|
const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)');
|
|
if (visibleRightModules.length === 0) {
|
|
rightColumn.classList.add('d-none');
|
|
rightColumn.classList.remove('col-xl-4');
|
|
rightColumn.classList.remove('col-lg-4');
|
|
leftColumn.classList.remove('col-xl-8');
|
|
leftColumn.classList.remove('col-lg-8');
|
|
leftColumn.classList.add('col-12');
|
|
} else {
|
|
rightColumn.classList.remove('d-none');
|
|
rightColumn.classList.add('col-xl-4');
|
|
rightColumn.classList.add('col-lg-4');
|
|
leftColumn.classList.add('col-xl-8');
|
|
leftColumn.classList.add('col-lg-8');
|
|
leftColumn.classList.remove('col-12');
|
|
}
|
|
}
|
|
|
|
function updateInnerColumnVisibility() {
|
|
const leftCol = document.getElementById('inner-left-col');
|
|
const centerCol = document.getElementById('inner-center-col');
|
|
if (!leftCol || !centerCol) return;
|
|
|
|
// Tæl synlige moduler i venstre kolonnen (mb-3 wrappers der ikke er skjulte)
|
|
const visibleLeftModules = leftCol.querySelectorAll('.mb-3:not(.d-none) [data-module]');
|
|
const hasVisibleLeft = visibleLeftModules.length > 0;
|
|
|
|
if (!hasVisibleLeft) {
|
|
// Ingen synlige moduler i venstre - center forbliver fuld bredde
|
|
leftCol.classList.add('d-none');
|
|
centerCol.classList.add('col-12');
|
|
} else {
|
|
// Begge interne sektioner vises stadig i én kolonne hver
|
|
leftCol.classList.remove('d-none');
|
|
centerCol.classList.add('col-12');
|
|
}
|
|
}
|
|
|
|
async function applyViewFromTags() {
|
|
try {
|
|
const res = await fetch(`/api/v1/tags/entity/case/${caseIds}`);
|
|
if (!res.ok) return;
|
|
const tags = await res.json();
|
|
const viewTag = tags.find(t => ['Pipeline', 'Kundevisning', 'Sag-detalje'].includes(t.name));
|
|
applyViewLayout(viewTag ? viewTag.name : 'Sag-detalje');
|
|
} catch (e) {
|
|
console.error('View tag lookup failed', e);
|
|
}
|
|
}
|
|
|
|
async function loadModulePrefs() {
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/modules`);
|
|
if (!res.ok) return;
|
|
const prefs = await res.json();
|
|
modulePrefs = (prefs || []).reduce((acc, p) => {
|
|
acc[p.module_key] = p.is_enabled;
|
|
return acc;
|
|
}, {});
|
|
modulePrefs.time = true;
|
|
} catch (e) {
|
|
console.error('Module prefs load failed', e);
|
|
}
|
|
}
|
|
|
|
async function loadCaseTypeModuleDefaultsSetting() {
|
|
try {
|
|
const res = await fetch('/api/v1/settings/case_type_module_defaults');
|
|
if (!res.ok) return;
|
|
const setting = await res.json();
|
|
const parsed = JSON.parse(setting.value || '{}');
|
|
if (parsed && typeof parsed === 'object') {
|
|
caseTypeModuleDefaults = Object.entries(parsed).reduce((acc, [key, value]) => {
|
|
acc[String(key || '').toLowerCase()] = Array.isArray(value) ? value : [];
|
|
return acc;
|
|
}, {});
|
|
} else {
|
|
caseTypeModuleDefaults = {};
|
|
}
|
|
} catch (e) {
|
|
console.error('Case type module defaults load failed', e);
|
|
caseTypeModuleDefaults = {};
|
|
}
|
|
}
|
|
|
|
async function openModuleControlModal() {
|
|
const list = document.getElementById('moduleControlList');
|
|
list.innerHTML = '<div class="text-muted small">Indlæser...</div>';
|
|
|
|
const modules = Array.from(document.querySelectorAll('[data-module]')).map(el => {
|
|
const key = el.getAttribute('data-module');
|
|
return { key, label: window.moduleDisplayNames[key] || key };
|
|
});
|
|
|
|
list.innerHTML = modules.map(m => {
|
|
const isTimeModule = m.key === 'time';
|
|
const checked = isTimeModule ? true : modulePrefs[m.key] !== false;
|
|
return `
|
|
<div class="form-check mb-2">
|
|
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
|
|
${isTimeModule ? 'disabled' : ''}
|
|
onchange="toggleModulePref('${m.key}', this.checked)">
|
|
<label class="form-check-label" for="module_${m.key}">${m.label}${isTimeModule ? ' (altid synlig)' : ''}</label>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('moduleControlModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function toggleModulePref(moduleKey, isEnabled) {
|
|
if (moduleKey === 'time') {
|
|
modulePrefs.time = true;
|
|
applyViewFromTags();
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/modules`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ module_key: moduleKey, is_enabled: isEnabled })
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Kunne ikke opdatere modul');
|
|
}
|
|
modulePrefs[moduleKey] = isEnabled;
|
|
applyViewFromTags();
|
|
} catch (e) {
|
|
alert('Fejl: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// FILES & EMAILS LOGIC
|
|
// ==========================================
|
|
|
|
let sagFilesCache = [];
|
|
|
|
// ---------------- FILES ----------------
|
|
|
|
function updateCaseEmailAttachmentOptions(files) {
|
|
const select = document.getElementById('caseEmailAttachmentIds');
|
|
if (!select) return;
|
|
|
|
const safeFiles = Array.isArray(files) ? files : [];
|
|
if (!safeFiles.length) {
|
|
select.innerHTML = '<option disabled>Ingen sagsfiler tilgængelige</option>';
|
|
return;
|
|
}
|
|
|
|
select.innerHTML = safeFiles.map((file) => {
|
|
const fileId = Number(file.id);
|
|
const filename = escapeHtml(file.filename || `Fil ${fileId}`);
|
|
const date = file.created_at ? new Date(file.created_at).toLocaleDateString('da-DK') : '-';
|
|
return `<option value="${fileId}">${filename} (${date})</option>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function loadSagFiles() {
|
|
const container = document.getElementById('files-list');
|
|
if (container) {
|
|
container.innerHTML = '<div class="p-3 text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Henter filer...</div>';
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/files`);
|
|
if(res.ok) {
|
|
const files = await res.json();
|
|
sagFilesCache = Array.isArray(files) ? files : [];
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|
renderFiles(files);
|
|
} else {
|
|
sagFilesCache = [];
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|
if (container) {
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
|
|
}
|
|
setModuleContentState('files', true);
|
|
}
|
|
} catch(e) {
|
|
console.error(e);
|
|
sagFilesCache = [];
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|
if (container) {
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
|
|
}
|
|
setModuleContentState('files', true);
|
|
}
|
|
}
|
|
|
|
function renderFiles(files) {
|
|
const container = document.getElementById('files-list');
|
|
sagFilesCache = Array.isArray(files) ? files : [];
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
if(!files || files.length === 0) {
|
|
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen filer fundet...</div>';
|
|
setModuleContentState('files', false);
|
|
return;
|
|
}
|
|
setModuleContentState('files', true);
|
|
container.innerHTML = files.map(f => {
|
|
const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB';
|
|
return `
|
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
<div class="ms-2 me-auto">
|
|
<div class="fw-bold text-truncate" style="max-width: 250px;">
|
|
<a href="javascript:void(0);" onclick="previewFile(${f.id}, '${f.filename.replace(/'/g, "\\'")}', '${f.content_type || ''}')" class="text-decoration-none text-dark">
|
|
<i class="bi bi-file-earmark me-1"></i> ${f.filename}
|
|
</a>
|
|
</div>
|
|
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
|
|
</div>
|
|
<div class="d-flex gap-1">
|
|
<a href="${f.download_url}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
|
|
<i class="bi bi-download"></i>
|
|
</a>
|
|
<button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${f.id})" title="Slet">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function handleFileUpload(fileList) {
|
|
if(!fileList || fileList.length === 0) return;
|
|
const formData = new FormData();
|
|
for (let i = 0; i < fileList.length; i++) {
|
|
formData.append("files", fileList[i]);
|
|
}
|
|
|
|
// Show loading
|
|
document.getElementById('files-list').innerHTML += '<div class="p-2 text-center text-muted fst-italic">Uploader...</div>';
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/files`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
if(res.ok) {
|
|
loadSagFiles();
|
|
} else {
|
|
alert('Upload fejlede');
|
|
loadSagFiles(); // Reload to clear loading state
|
|
}
|
|
} catch(e) {
|
|
alert('Upload fejl: ' + e);
|
|
loadSagFiles();
|
|
}
|
|
}
|
|
|
|
async function deleteFile(fileId) {
|
|
if(!confirm("Slet denne fil?")) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/files/${fileId}`, { method: 'DELETE' });
|
|
if(res.ok) loadSagFiles();
|
|
else alert("Kunne ikke slette fil");
|
|
} catch(e) { alert("Fejl: " + e); }
|
|
}
|
|
|
|
// File Preview
|
|
function previewFile(fileId, filename, contentType) {
|
|
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
|
|
const previewContent = document.getElementById('previewContent');
|
|
const fileNameEl = document.getElementById('previewFileName');
|
|
const downloadBtn = document.getElementById('previewDownloadBtn');
|
|
|
|
// Set filename and download link
|
|
fileNameEl.textContent = filename;
|
|
const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`;
|
|
downloadBtn.href = `${fileUrl}?download=true`;
|
|
downloadBtn.download = filename;
|
|
|
|
// Show loading spinner
|
|
previewContent.innerHTML = `
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Indlæser...</span>
|
|
</div>
|
|
`;
|
|
|
|
modal.show();
|
|
|
|
// Determine file type and render preview
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(ext)) {
|
|
// Image preview
|
|
previewContent.innerHTML = `<img src="${fileUrl}" class="img-fluid" style="max-height: 80vh;" alt="${filename}">`;
|
|
} else if (ext === 'pdf') {
|
|
// PDF preview using iframe
|
|
previewContent.innerHTML = `<iframe src="${fileUrl}" class="w-100 h-100 border-0" style="min-height: 60vh;"></iframe>`;
|
|
} else if (['txt', 'log', 'md', 'json', 'xml', 'csv', 'html', 'css', 'js', 'py', 'sql'].includes(ext)) {
|
|
// Text file preview
|
|
fetch(fileUrl)
|
|
.then(res => res.text())
|
|
.then(text => {
|
|
previewContent.innerHTML = `<pre class="p-3 m-0 overflow-auto h-100" style="max-height: 80vh;"><code>${escapeHtml(text)}</code></pre>`;
|
|
})
|
|
.catch(err => {
|
|
previewContent.innerHTML = `<div class="alert alert-danger m-4">Kunne ikke indlæse fil: ${err}</div>`;
|
|
});
|
|
} else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) {
|
|
// Office documents - use Google Docs Viewer
|
|
const encodedUrl = encodeURIComponent(window.location.origin + fileUrl);
|
|
previewContent.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${encodedUrl}&embedded=true" class="w-100 h-100 border-0" style="min-height: 60vh;"></iframe>`;
|
|
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
|
|
// Video preview
|
|
previewContent.innerHTML = `
|
|
<video controls class="w-100" style="max-height: 80vh;">
|
|
<source src="${fileUrl}" type="video/${ext}">
|
|
Din browser understøtter ikke video afspilning.
|
|
</video>
|
|
`;
|
|
} else if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) {
|
|
// Audio preview
|
|
previewContent.innerHTML = `
|
|
<div class="p-5 text-center">
|
|
<i class="bi bi-music-note-beamed display-1 text-muted mb-4"></i>
|
|
<h5>${filename}</h5>
|
|
<audio controls class="w-100 mt-3">
|
|
<source src="${fileUrl}" type="audio/${ext}">
|
|
Din browser understøtter ikke audio afspilning.
|
|
</audio>
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Unsupported file type
|
|
previewContent.innerHTML = `
|
|
<div class="p-5 text-center">
|
|
<i class="bi bi-file-earmark-x display-1 text-muted mb-4"></i>
|
|
<h5>Kan ikke vise forhåndsvisning for denne filtype</h5>
|
|
<p class="text-muted">${filename}</p>
|
|
<a href="${fileUrl}?download=true" class="btn btn-primary mt-3" download="${filename}">
|
|
<i class="bi bi-download me-2"></i> Download fil
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// File Drag & Drop
|
|
const fileDropZone = document.getElementById('fileDropZone');
|
|
if(fileDropZone) {
|
|
fileDropZone.addEventListener('dragover', e => { e.preventDefault(); fileDropZone.classList.add('bg-light-subtle'); });
|
|
fileDropZone.addEventListener('dragleave', e => { e.preventDefault(); fileDropZone.classList.remove('bg-light-subtle'); });
|
|
fileDropZone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
fileDropZone.classList.remove('bg-light-subtle');
|
|
if(e.dataTransfer.files.length) handleFileUpload(e.dataTransfer.files);
|
|
});
|
|
}
|
|
|
|
// ---------------- EMAILS ----------------
|
|
|
|
let linkedEmailsCache = [];
|
|
let filteredLinkedEmailsCache = [];
|
|
let selectedLinkedEmailId = null;
|
|
let selectedLinkedEmailDetail = null;
|
|
let selectedEmailThreadKey = null;
|
|
|
|
function parseEmailField(value) {
|
|
return String(value || '')
|
|
.split(/[\n,;]+/)
|
|
.map((email) => email.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function escapeHtmlForInput(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
let rewriteReviewState = null;
|
|
|
|
function extractRewriteBody(rawText, context) {
|
|
const text = String(rawText || '').trim();
|
|
if (!text) return '';
|
|
|
|
if (context === 'email') {
|
|
const bodyMatch = text.match(/(?:^|\n)Besked:\s*\n([\s\S]*)$/i);
|
|
if (bodyMatch?.[1]) return bodyMatch[1].trim();
|
|
return text;
|
|
}
|
|
|
|
if (context === 'case') {
|
|
const descMatch = text.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i);
|
|
if (descMatch?.[1]) return descMatch[1].trim();
|
|
return text;
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
function buildLineDiff(originalText, rewrittenText) {
|
|
const originalLines = String(originalText || '').split('\n');
|
|
const rewrittenLines = String(rewrittenText || '').split('\n');
|
|
const maxLen = Math.max(originalLines.length, rewrittenLines.length);
|
|
const changes = [];
|
|
|
|
for (let idx = 0; idx < maxLen; idx += 1) {
|
|
const before = originalLines[idx] ?? '';
|
|
const after = rewrittenLines[idx] ?? '';
|
|
if (before !== after) {
|
|
changes.push({ index: idx, before, after });
|
|
}
|
|
}
|
|
|
|
return { changes, originalLines, rewrittenLines };
|
|
}
|
|
|
|
function updateRewriteSelectionInfo() {
|
|
const infoEl = document.getElementById('rewritePreviewSelectionInfo');
|
|
const selectedCount = document.querySelectorAll('.rewrite-change-check:checked').length;
|
|
const totalCount = rewriteReviewState?.changes?.length || 0;
|
|
if (!infoEl) return;
|
|
infoEl.textContent = `${selectedCount} af ${totalCount} ændringer valgt`;
|
|
}
|
|
|
|
function renderRewritePreview(changes) {
|
|
const listEl = document.getElementById('rewritePreviewList');
|
|
const noChangesEl = document.getElementById('rewritePreviewNoChanges');
|
|
if (!listEl || !noChangesEl) return;
|
|
|
|
if (!changes.length) {
|
|
listEl.innerHTML = '';
|
|
noChangesEl.classList.remove('d-none');
|
|
return;
|
|
}
|
|
|
|
noChangesEl.classList.add('d-none');
|
|
listEl.innerHTML = changes.map((change, i) => `
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body py-2 px-3">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<div class="form-check">
|
|
<input class="form-check-input rewrite-change-check" type="checkbox" value="${change.index}" id="rewriteChange_${change.index}" checked>
|
|
<label class="form-check-label small fw-semibold" for="rewriteChange_${change.index}">
|
|
Ændring ${i + 1} (linje ${change.index + 1})
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="row g-2">
|
|
<div class="col-md-6">
|
|
<div class="small text-muted mb-1">Før</div>
|
|
<div class="border rounded p-2 bg-light" style="white-space: pre-wrap; min-height: 44px;">${escapeHtml(change.before) || '<span class="text-muted fst-italic">(tom)</span>'}</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="small text-muted mb-1">Efter</div>
|
|
<div class="border rounded p-2" style="white-space: pre-wrap; min-height: 44px; background: rgba(15,76,117,0.08);">${escapeHtml(change.after) || '<span class="text-muted fst-italic">(tom)</span>'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
listEl.querySelectorAll('.rewrite-change-check').forEach((input) => {
|
|
input.addEventListener('change', updateRewriteSelectionInfo);
|
|
});
|
|
updateRewriteSelectionInfo();
|
|
}
|
|
|
|
function applyRewriteChanges(mode) {
|
|
if (!rewriteReviewState) return;
|
|
|
|
const { originalLines, rewrittenLines, applyToTarget } = rewriteReviewState;
|
|
if (mode === 'all') {
|
|
applyToTarget(rewrittenLines.join('\n'));
|
|
return;
|
|
}
|
|
|
|
const selectedIndexes = new Set(
|
|
Array.from(document.querySelectorAll('.rewrite-change-check:checked'))
|
|
.map((el) => Number(el.value))
|
|
.filter((val) => Number.isInteger(val) && val >= 0)
|
|
);
|
|
|
|
const merged = [...originalLines];
|
|
for (let idx = 0; idx < rewrittenLines.length; idx += 1) {
|
|
if (selectedIndexes.has(idx)) {
|
|
merged[idx] = rewrittenLines[idx] ?? '';
|
|
}
|
|
}
|
|
applyToTarget(merged.join('\n'));
|
|
}
|
|
|
|
function openRewriteReviewModal({ title, originalText, rewrittenText, applyToTarget }) {
|
|
const summaryEl = document.getElementById('rewritePreviewSummary');
|
|
const applyAllBtn = document.getElementById('rewriteApplyAllBtn');
|
|
const applySelectedBtn = document.getElementById('rewriteApplySelectedBtn');
|
|
const modalEl = document.getElementById('rewritePreviewModal');
|
|
if (!summaryEl || !applyAllBtn || !applySelectedBtn || !modalEl) return;
|
|
|
|
const diff = buildLineDiff(originalText, rewrittenText);
|
|
rewriteReviewState = {
|
|
...diff,
|
|
applyToTarget,
|
|
};
|
|
|
|
summaryEl.textContent = `${title}: ${diff.changes.length} foreslaaede ændringer.`;
|
|
renderRewritePreview(diff.changes);
|
|
|
|
applyAllBtn.disabled = !diff.changes.length;
|
|
applySelectedBtn.disabled = !diff.changes.length;
|
|
|
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
modal.show();
|
|
}
|
|
|
|
async function requestRewriteSuggestion(endpoint, text, context) {
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text, context })
|
|
});
|
|
if (!response.ok) {
|
|
let detail = `HTTP ${response.status}`;
|
|
try {
|
|
const err = await response.json();
|
|
if (err?.detail) detail = err.detail;
|
|
} catch (_) {}
|
|
throw new Error(detail);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
window.rewriteCaseEmailWithApproval = async function () {
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|
const btn = document.getElementById('caseEmailRewriteBtn');
|
|
if (!bodyInput) return;
|
|
|
|
const source = (bodyInput.value || '').trim();
|
|
if (!source) {
|
|
alert('Skriv en besked først.');
|
|
return;
|
|
}
|
|
|
|
const originalHtml = btn?.innerHTML || '';
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
|
|
}
|
|
|
|
try {
|
|
const payload = await requestRewriteSuggestion('/api/v1/emails/rewrite-text', source, 'email');
|
|
const rewritten = extractRewriteBody(payload?.rewritten_text || '', 'email');
|
|
openRewriteReviewModal({
|
|
title: 'Email-tekst',
|
|
originalText: source,
|
|
rewrittenText: rewritten,
|
|
applyToTarget: (nextText) => {
|
|
bodyInput.value = nextText;
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide();
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert(`Kunne ikke renskrive email: ${error.message || 'Ukendt fejl'}`);
|
|
} finally {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
};
|
|
|
|
function getDefaultCaseRecipient() {
|
|
const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]');
|
|
if (primaryContact?.dataset?.email) {
|
|
return primaryContact.dataset.email.trim();
|
|
}
|
|
|
|
const anyContact = document.querySelector('.contact-row[data-email]');
|
|
if (anyContact?.dataset?.email) {
|
|
return anyContact.dataset.email.trim();
|
|
}
|
|
|
|
const customerSmall = document.querySelector('.customer-row small');
|
|
if (customerSmall) {
|
|
const text = customerSmall.textContent || '';
|
|
const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
|
if (match) {
|
|
return match[0].trim();
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function prefillCaseEmailCompose() {
|
|
const toInput = document.getElementById('caseEmailTo');
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|
|
|
if (toInput && !toInput.value.trim()) {
|
|
const recipient = getDefaultCaseRecipient();
|
|
if (recipient) {
|
|
toInput.value = recipient;
|
|
}
|
|
}
|
|
|
|
if (subjectInput && !subjectInput.value.trim()) {
|
|
subjectInput.value = escapeHtmlForInput(`Sag #${caseIds}: `);
|
|
}
|
|
}
|
|
|
|
function openReplyToLinkedEmail() {
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|
if (!composeModalEl || !selectedLinkedEmailId || !selectedLinkedEmailDetail) {
|
|
return;
|
|
}
|
|
|
|
const toInput = document.getElementById('caseEmailTo');
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|
|
|
const senderEmail = (selectedLinkedEmailDetail.sender_email || '').trim();
|
|
const originalSubject = (selectedLinkedEmailDetail.subject || '').trim();
|
|
|
|
if (toInput && !toInput.value.trim() && senderEmail) {
|
|
toInput.value = senderEmail;
|
|
}
|
|
|
|
if (subjectInput && !subjectInput.value.trim()) {
|
|
const replySubject = /^re:\s*/i.test(originalSubject)
|
|
? originalSubject
|
|
: `Re: ${originalSubject || `Sag #${caseIds}`}`;
|
|
subjectInput.value = escapeHtmlForInput(replySubject);
|
|
}
|
|
|
|
if (bodyInput && !bodyInput.value.trim()) {
|
|
const received = selectedLinkedEmailDetail.received_date
|
|
? new Date(selectedLinkedEmailDetail.received_date).toLocaleString('da-DK')
|
|
: '-';
|
|
const senderName = selectedLinkedEmailDetail.sender_name || senderEmail || 'Ukendt';
|
|
bodyInput.value = `\n\n---\nFra: ${senderName}\nDato: ${received}\nEmne: ${originalSubject || '(Ingen emne)'}\n`;
|
|
}
|
|
|
|
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
|
|
}
|
|
|
|
async function sendCaseEmail() {
|
|
const toInput = document.getElementById('caseEmailTo');
|
|
const ccInput = document.getElementById('caseEmailCc');
|
|
const bccInput = document.getElementById('caseEmailBcc');
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|
const attachmentSelect = document.getElementById('caseEmailAttachmentIds');
|
|
const sendBtn = document.getElementById('caseEmailSendBtn');
|
|
const statusEl = document.getElementById('caseEmailSendStatus');
|
|
|
|
if (!toInput || !subjectInput || !bodyInput || !sendBtn || !statusEl) {
|
|
return;
|
|
}
|
|
|
|
const to = parseEmailField(toInput.value);
|
|
const cc = parseEmailField(ccInput?.value || '');
|
|
const bcc = parseEmailField(bccInput?.value || '');
|
|
const subject = (subjectInput.value || '').trim();
|
|
const bodyText = (bodyInput.value || '').trim();
|
|
const attachmentFileIds = Array.from(attachmentSelect?.selectedOptions || [])
|
|
.map((opt) => Number(opt.value))
|
|
.filter((id) => Number.isInteger(id) && id > 0);
|
|
|
|
if (!to.length) {
|
|
alert('Udfyld mindst én modtager i Til-feltet.');
|
|
return;
|
|
}
|
|
|
|
if (!subject) {
|
|
alert('Udfyld emne før afsendelse.');
|
|
return;
|
|
}
|
|
|
|
if (!bodyText) {
|
|
alert('Udfyld besked før afsendelse.');
|
|
return;
|
|
}
|
|
|
|
sendBtn.disabled = true;
|
|
statusEl.className = 'text-muted';
|
|
statusEl.textContent = 'Sender e-mail...';
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/emails/send`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
to,
|
|
cc,
|
|
bcc,
|
|
subject,
|
|
body_text: bodyText,
|
|
attachment_file_ids: attachmentFileIds,
|
|
thread_email_id: selectedLinkedEmailId || null,
|
|
thread_key: (
|
|
linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key
|
|
|| linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.resolved_thread_key
|
|
|| null
|
|
)
|
|
})
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`;
|
|
try {
|
|
const responseText = await res.text();
|
|
if (responseText) {
|
|
try {
|
|
const err = JSON.parse(responseText);
|
|
if (err?.detail) {
|
|
message = err.detail;
|
|
} else if (err?.message) {
|
|
message = err.message;
|
|
}
|
|
} catch (_) {
|
|
message = responseText.slice(0, 500);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
|
|
if (subjectInput) subjectInput.value = '';
|
|
if (bodyInput) bodyInput.value = '';
|
|
if (ccInput) ccInput.value = '';
|
|
if (bccInput) bccInput.value = '';
|
|
if (attachmentSelect) {
|
|
Array.from(attachmentSelect.options).forEach((option) => {
|
|
option.selected = false;
|
|
});
|
|
}
|
|
|
|
statusEl.className = 'text-success';
|
|
statusEl.textContent = 'E-mail sendt.';
|
|
loadLinkedEmails();
|
|
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|
const composeModal = composeModalEl ? bootstrap.Modal.getInstance(composeModalEl) : null;
|
|
if (composeModal) {
|
|
composeModal.hide();
|
|
}
|
|
} catch (error) {
|
|
statusEl.className = 'text-danger';
|
|
statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)';
|
|
} finally {
|
|
sendBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function openCaseEmailTab() {
|
|
const trigger = document.getElementById('emails-tab');
|
|
if (!trigger) return;
|
|
const instance = bootstrap.Tab.getOrCreateInstance(trigger);
|
|
instance.show();
|
|
}
|
|
|
|
window.quickReplyToEmailFromComment = async function(emailId) {
|
|
const parsedId = Number(emailId);
|
|
if (!Number.isFinite(parsedId)) return;
|
|
|
|
openCaseEmailTab();
|
|
|
|
try {
|
|
await loadLinkedEmails();
|
|
await loadLinkedEmailDetail(parsedId);
|
|
openReplyToLinkedEmail();
|
|
} catch (error) {
|
|
console.error('Kunne ikke starte quick svar fra kommentar:', error);
|
|
}
|
|
}
|
|
|
|
async function loadLinkedEmails() {
|
|
const container = document.getElementById('linked-emails-list');
|
|
const threadContainer = document.getElementById('email-threads-list');
|
|
if (!container || !threadContainer) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`);
|
|
if(res.ok) {
|
|
linkedEmailsCache = await res.json();
|
|
await applyLinkedEmailFilters(true);
|
|
} else {
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
|
|
threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>';
|
|
setModuleContentState('emails', true);
|
|
}
|
|
} catch(e) {
|
|
console.error(e);
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
|
|
threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>';
|
|
setModuleContentState('emails', true);
|
|
}
|
|
}
|
|
|
|
function getFilteredLinkedEmails() {
|
|
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
|
|
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
|
|
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
|
|
|
|
return linkedEmailsCache.filter((email) => {
|
|
if (textFilter) {
|
|
const haystack = [
|
|
email.subject,
|
|
email.sender_email,
|
|
email.sender_name,
|
|
email.body_text,
|
|
email.body_html
|
|
].join(' ').toLowerCase();
|
|
if (!haystack.includes(textFilter)) return false;
|
|
}
|
|
|
|
const hasAttachments = Boolean(email.has_attachments) || Number(email.attachment_count || 0) > 0;
|
|
if (attachmentFilter === 'with' && !hasAttachments) return false;
|
|
if (attachmentFilter === 'without' && hasAttachments) return false;
|
|
|
|
const isRead = Boolean(email.is_read);
|
|
if (readFilter === 'read' && !isRead) return false;
|
|
if (readFilter === 'unread' && isRead) return false;
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function getThreadKey(email) {
|
|
return (email?.resolved_thread_key || email?.thread_key || `email-${email?.id || 'unknown'}`).toString();
|
|
}
|
|
|
|
function isOutgoingEmail(email) {
|
|
if (typeof email?.is_outgoing === 'boolean') {
|
|
return email.is_outgoing;
|
|
}
|
|
const folder = (email?.folder || '').toString().toLowerCase();
|
|
const status = (email?.status || '').toString().toLowerCase();
|
|
return folder.startsWith('sent') || status === 'sent';
|
|
}
|
|
|
|
function buildThreadGroups(emails) {
|
|
const map = new Map();
|
|
|
|
emails.forEach((email) => {
|
|
const threadKey = getThreadKey(email);
|
|
const existing = map.get(threadKey);
|
|
const receivedDateMs = email.received_date ? new Date(email.received_date).getTime() : 0;
|
|
|
|
if (!existing) {
|
|
map.set(threadKey, {
|
|
threadKey,
|
|
lastDateMs: receivedDateMs,
|
|
latestEmail: email,
|
|
emails: [email]
|
|
});
|
|
return;
|
|
}
|
|
|
|
existing.emails.push(email);
|
|
if (receivedDateMs > existing.lastDateMs) {
|
|
existing.lastDateMs = receivedDateMs;
|
|
existing.latestEmail = email;
|
|
}
|
|
});
|
|
|
|
return Array.from(map.values())
|
|
.map((group) => {
|
|
group.emails.sort((a, b) => {
|
|
const aDate = a.received_date ? new Date(a.received_date).getTime() : 0;
|
|
const bDate = b.received_date ? new Date(b.received_date).getTime() : 0;
|
|
return bDate - aDate;
|
|
});
|
|
return group;
|
|
})
|
|
.sort((a, b) => b.lastDateMs - a.lastDateMs);
|
|
}
|
|
|
|
function getCurrentThreadEmails() {
|
|
if (!selectedEmailThreadKey) return [];
|
|
return filteredLinkedEmailsCache
|
|
.filter((email) => getThreadKey(email) === selectedEmailThreadKey)
|
|
.sort((a, b) => {
|
|
const aDate = a.received_date ? new Date(a.received_date).getTime() : 0;
|
|
const bDate = b.received_date ? new Date(b.received_date).getTime() : 0;
|
|
return bDate - aDate;
|
|
});
|
|
}
|
|
|
|
function renderEmailThreads(threadGroups) {
|
|
const container = document.getElementById('email-threads-list');
|
|
if (!container) return;
|
|
|
|
if (!threadGroups.length) {
|
|
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen tråde fundet...</div>';
|
|
const counter = document.getElementById('linkedEmailThreadsCount');
|
|
if (counter) counter.textContent = '0';
|
|
return;
|
|
}
|
|
|
|
const counter = document.getElementById('linkedEmailThreadsCount');
|
|
if (counter) counter.textContent = String(threadGroups.length);
|
|
|
|
container.innerHTML = threadGroups.map((group) => {
|
|
const latest = group.latestEmail || {};
|
|
const isSelected = selectedEmailThreadKey === group.threadKey;
|
|
const receivedDate = latest.received_date ? new Date(latest.received_date).toLocaleString('da-DK') : '-';
|
|
const sender = latest.sender_name || latest.sender_email || '-';
|
|
const subject = latest.subject || '(Ingen emne)';
|
|
const unreadCount = group.emails.filter((item) => !item.is_read).length;
|
|
|
|
return `
|
|
<button type="button" class="list-group-item list-group-item-action border-0 border-bottom text-start ${isSelected ? 'active' : ''}" onclick='selectEmailThread(${JSON.stringify(group.threadKey)})'>
|
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
|
<div class="flex-grow-1 overflow-hidden">
|
|
<div class="fw-semibold text-truncate">${escapeHtml(subject)}</div>
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(sender)}</div>
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(receivedDate)}</div>
|
|
</div>
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|
<span class="badge ${isSelected ? 'bg-light text-dark' : 'bg-secondary'}">${group.emails.length}</span>
|
|
${unreadCount > 0 ? `<span class="badge bg-warning text-dark">${unreadCount} ulæst</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function selectEmailThread(threadKey) {
|
|
selectedEmailThreadKey = String(threadKey || '');
|
|
|
|
const threadGroups = buildThreadGroups(filteredLinkedEmailsCache);
|
|
renderEmailThreads(threadGroups);
|
|
|
|
const threadEmails = getCurrentThreadEmails();
|
|
renderLinkedEmails(threadEmails);
|
|
|
|
if (!threadEmails.length) {
|
|
selectedLinkedEmailId = null;
|
|
renderEmailPreviewEmpty();
|
|
return;
|
|
}
|
|
|
|
const hasCurrentSelected = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId));
|
|
if (!hasCurrentSelected) {
|
|
selectedLinkedEmailId = Number(threadEmails[0].id);
|
|
}
|
|
|
|
loadLinkedEmailDetail(selectedLinkedEmailId, true);
|
|
}
|
|
|
|
async function applyLinkedEmailFilters(loadDetail = false) {
|
|
filteredLinkedEmailsCache = getFilteredLinkedEmails();
|
|
const threadGroups = buildThreadGroups(filteredLinkedEmailsCache);
|
|
|
|
renderEmailThreads(threadGroups);
|
|
|
|
if (!threadGroups.length) {
|
|
selectedEmailThreadKey = null;
|
|
selectedLinkedEmailId = null;
|
|
renderLinkedEmails([]);
|
|
const threadCounter = document.getElementById('threadEmailsCount');
|
|
if (threadCounter) threadCounter.textContent = '0';
|
|
renderEmailPreviewEmpty();
|
|
setModuleContentState('emails', false);
|
|
return;
|
|
}
|
|
|
|
const selectedThreadExists = threadGroups.some((group) => group.threadKey === selectedEmailThreadKey);
|
|
if (!selectedThreadExists) {
|
|
selectedEmailThreadKey = threadGroups[0].threadKey;
|
|
}
|
|
|
|
const threadEmails = getCurrentThreadEmails();
|
|
renderLinkedEmails(threadEmails);
|
|
|
|
const threadCounter = document.getElementById('threadEmailsCount');
|
|
if (threadCounter) threadCounter.textContent = String(threadEmails.length);
|
|
|
|
if (!threadEmails.length) {
|
|
selectedLinkedEmailId = null;
|
|
renderEmailPreviewEmpty();
|
|
return;
|
|
}
|
|
|
|
const selectedEmailExists = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId));
|
|
if (!selectedEmailExists) {
|
|
selectedLinkedEmailId = Number(threadEmails[0].id);
|
|
}
|
|
|
|
if (loadDetail && selectedLinkedEmailId) {
|
|
await loadLinkedEmailDetail(selectedLinkedEmailId, true);
|
|
}
|
|
|
|
setModuleContentState('emails', true);
|
|
}
|
|
|
|
function renderLinkedEmails(emails) {
|
|
const container = document.getElementById('linked-emails-list');
|
|
if (!container) return;
|
|
if(!emails || emails.length === 0) {
|
|
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen linkede e-mails...</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = emails.map(e => {
|
|
const isSelected = Number(selectedLinkedEmailId) === Number(e.id);
|
|
const receivedDate = e.received_date ? new Date(e.received_date).toLocaleString('da-DK') : '-';
|
|
const sender = e.sender_name || e.sender_email || '-';
|
|
const subject = e.subject || '(Ingen emne)';
|
|
const isOutgoing = isOutgoingEmail(e);
|
|
const snippetSource = e.body_text || e.body_html || '';
|
|
const snippet = snippetSource.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 130);
|
|
const hasAttachments = Boolean(e.has_attachments) || Number(e.attachment_count || 0) > 0;
|
|
|
|
return `
|
|
<button type="button" class="list-group-item list-group-item-action border-0 border-bottom text-start ${isSelected ? 'active' : ''}" onclick="loadLinkedEmailDetail(${e.id})">
|
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
|
<div class="flex-grow-1 overflow-hidden">
|
|
<div class="fw-semibold text-truncate">${escapeHtml(subject)}</div>
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(sender)}</div>
|
|
<div class="small mt-1">${isOutgoing
|
|
? '<span class="badge bg-primary-subtle text-primary-emphasis">Udgående</span>'
|
|
: '<span class="badge bg-success-subtle text-success-emphasis">Indgående</span>'
|
|
}</div>
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(snippet || 'Ingen preview')}</div>
|
|
</div>
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'}">${escapeHtml(receivedDate)}</div>
|
|
${hasAttachments ? '<span class="badge bg-info-subtle text-info-emphasis">📎</span>' : ''}
|
|
${!e.is_read ? '<span class="badge bg-warning text-dark">Ulæst</span>' : ''}
|
|
<span class="btn btn-sm btn-link p-0 ${isSelected ? 'text-white' : 'text-danger'}" onclick="event.stopPropagation(); unlinkEmail(${e.id});" title="Fjern link">
|
|
<i class="bi bi-link-45deg" style="text-decoration: line-through;"></i>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderEmailPreviewEmpty() {
|
|
const panel = document.getElementById('email-preview-panel');
|
|
if (!panel) return;
|
|
selectedLinkedEmailDetail = null;
|
|
panel.innerHTML = `
|
|
<div class="p-3 text-center text-muted d-flex align-items-center justify-content-center flex-grow-1">
|
|
Vælg en e-mail i listen for at se indhold og vedhæftninger
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function loadLinkedEmailDetail(emailId, skipRefresh = false) {
|
|
selectedLinkedEmailId = Number(emailId);
|
|
const panel = document.getElementById('email-preview-panel');
|
|
if (!panel) return;
|
|
|
|
panel.innerHTML = `
|
|
<div class="p-4 text-center text-muted">
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
Henter e-mail...
|
|
</div>
|
|
`;
|
|
|
|
if (!skipRefresh) {
|
|
const threadEmails = getCurrentThreadEmails();
|
|
renderLinkedEmails(threadEmails);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/emails/${emailId}`);
|
|
if (!res.ok) {
|
|
panel.innerHTML = '<div class="p-3 text-danger">Kunne ikke hente e-mail detaljer.</div>';
|
|
return;
|
|
}
|
|
|
|
const email = await res.json();
|
|
const subject = email.subject || '(Ingen emne)';
|
|
const sender = email.sender_name || email.sender_email || '-';
|
|
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
|
|
const attachments = Array.isArray(email.attachments) ? email.attachments : [];
|
|
const bodyText = email.body_text || '';
|
|
const bodyHtml = email.body_html || '';
|
|
selectedLinkedEmailDetail = email;
|
|
|
|
panel.innerHTML = `
|
|
<div class="border-bottom p-3">
|
|
<div class="fw-bold mb-1">${escapeHtml(subject)}</div>
|
|
<div class="small text-muted">Fra: ${escapeHtml(sender)}</div>
|
|
<div class="small text-muted">Dato: ${escapeHtml(received)}</div>
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button type="button" class="btn btn-sm btn-primary" onclick="openReplyToLinkedEmail()">
|
|
<i class="bi bi-reply me-1"></i>Svar i tråd
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="p-3 border-bottom">
|
|
<div class="small fw-semibold mb-2">Vedhæftninger (${attachments.length})</div>
|
|
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
|
|
</div>
|
|
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
|
|
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml ? bodyHtml : '<div class="text-muted">Ingen indhold</div>')}
|
|
</div>
|
|
`;
|
|
|
|
const attachmentContainer = document.getElementById('email-attachments-list');
|
|
if (attachmentContainer) {
|
|
if (!attachments.length) {
|
|
attachmentContainer.innerHTML = '<span class="text-muted small">Ingen vedhæftninger</span>';
|
|
} else {
|
|
attachmentContainer.innerHTML = attachments.map(att => {
|
|
const attachmentName = att.filename || `Vedhæftning ${att.id}`;
|
|
const url = `/api/v1/emails/${email.id}/attachments/${att.id}`;
|
|
return `<a class="btn btn-sm btn-outline-secondary" href="${url}"><i class="bi bi-download me-1"></i>${escapeHtml(attachmentName)}</a>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
const cacheIdx = linkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id));
|
|
if (cacheIdx >= 0) {
|
|
linkedEmailsCache[cacheIdx].is_read = true;
|
|
}
|
|
|
|
const filteredIdx = filteredLinkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id));
|
|
if (filteredIdx >= 0) {
|
|
filteredLinkedEmailsCache[filteredIdx].is_read = true;
|
|
}
|
|
|
|
if (!skipRefresh) {
|
|
const threadEmails = getCurrentThreadEmails();
|
|
renderLinkedEmails(threadEmails);
|
|
renderEmailThreads(buildThreadGroups(filteredLinkedEmailsCache));
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
selectedLinkedEmailDetail = null;
|
|
panel.innerHTML = '<div class="p-3 text-danger">Fejl ved hentning af e-mail detaljer.</div>';
|
|
}
|
|
}
|
|
|
|
async function unlinkEmail(emailId) {
|
|
if(!confirm("Fjern link til denne email?")) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/email-links/${emailId}`, { method: 'DELETE' });
|
|
if(res.ok) {
|
|
if (Number(selectedLinkedEmailId) === Number(emailId)) {
|
|
selectedLinkedEmailId = null;
|
|
renderEmailPreviewEmpty();
|
|
}
|
|
loadLinkedEmails();
|
|
}
|
|
} catch(e) { alert(e); }
|
|
}
|
|
|
|
// Email Search
|
|
const emailSearchInput = document.getElementById('emailSearchInput');
|
|
const emailSearchResults = document.getElementById('emailSearchResults');
|
|
let emailDebounce = null;
|
|
|
|
if(emailSearchInput) {
|
|
emailSearchInput.addEventListener('input', e => {
|
|
clearTimeout(emailDebounce);
|
|
const q = e.target.value.trim();
|
|
if(q.length < 2) {
|
|
emailSearchResults.style.display = 'none';
|
|
return;
|
|
}
|
|
emailDebounce = setTimeout(() => searchEmails(q), 300);
|
|
});
|
|
|
|
// Hide on outside click
|
|
document.addEventListener('click', e => {
|
|
if(!emailSearchInput.contains(e.target) && !emailSearchResults.contains(e.target)) {
|
|
emailSearchResults.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
['emailFilterInput', 'emailAttachmentFilter', 'emailReadFilter'].forEach((id) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const eventName = id === 'emailFilterInput' ? 'input' : 'change';
|
|
el.addEventListener(eventName, () => {
|
|
applyLinkedEmailFilters(true);
|
|
});
|
|
});
|
|
|
|
async function searchEmails(query) {
|
|
try {
|
|
const res = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
|
|
if(res.ok) {
|
|
const emails = await res.json();
|
|
renderEmailSuggestions(emails);
|
|
emailSearchResults.style.display = 'block';
|
|
}
|
|
} catch(e) { console.error(e); }
|
|
}
|
|
|
|
function renderEmailSuggestions(emails) {
|
|
if(!emails.length) {
|
|
emailSearchResults.innerHTML = '<div class="list-group-item text-muted">Ingen fundet</div>';
|
|
return;
|
|
}
|
|
emailSearchResults.innerHTML = emails.map(e => `
|
|
<button class="list-group-item list-group-item-action" onclick="linkEmail(${e.id})">
|
|
<div class="fw-bold text-truncate">${e.subject}</div>
|
|
<div class="small text-muted">${e.sender_email}</div>
|
|
</button>
|
|
`).join('');
|
|
}
|
|
|
|
async function linkEmail(emailId) {
|
|
emailSearchInput.value = '';
|
|
emailSearchResults.style.display = 'none';
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({email_id: emailId})
|
|
});
|
|
if(res.ok) loadLinkedEmails();
|
|
else alert("Kunne ikke linke email");
|
|
} catch(e) { alert(e); }
|
|
}
|
|
|
|
// Email Import Drag & Drop (.msg / .eml)
|
|
const emailDropZone = document.getElementById('emailDropZone');
|
|
if(emailDropZone) {
|
|
emailDropZone.addEventListener('dragover', e => { e.preventDefault(); emailDropZone.classList.add('bg-warning-subtle'); });
|
|
emailDropZone.addEventListener('dragleave', e => { e.preventDefault(); emailDropZone.classList.remove('bg-warning-subtle'); });
|
|
emailDropZone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
emailDropZone.classList.remove('bg-warning-subtle');
|
|
const files = e.dataTransfer.files;
|
|
if(files.length) uploadEmailFile(files[0]);
|
|
});
|
|
}
|
|
|
|
async function uploadEmailFile(file) {
|
|
if (!file) return;
|
|
const lowerName = String(file.name || '').toLowerCase();
|
|
if (!(lowerName.endsWith('.eml') || lowerName.endsWith('.msg'))) {
|
|
alert('Kun .eml og .msg filer understøttes');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
// Show busy indicator
|
|
emailDropZone.style.opacity = '0.5';
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/upload-email`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
if(res.ok) {
|
|
loadLinkedEmails();
|
|
} else {
|
|
alert('Import fejlede');
|
|
}
|
|
} catch(e) { alert(e); }
|
|
finally {
|
|
emailDropZone.style.opacity = '1';
|
|
}
|
|
}
|
|
|
|
// Load content on start
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const caseEmailSendBtn = document.getElementById('caseEmailSendBtn');
|
|
if (caseEmailSendBtn) {
|
|
caseEmailSendBtn.addEventListener('click', sendCaseEmail);
|
|
}
|
|
|
|
const caseEmailRewriteBtn = document.getElementById('caseEmailRewriteBtn');
|
|
if (caseEmailRewriteBtn) {
|
|
caseEmailRewriteBtn.addEventListener('click', rewriteCaseEmailWithApproval);
|
|
}
|
|
|
|
const rewriteApplyAllBtn = document.getElementById('rewriteApplyAllBtn');
|
|
if (rewriteApplyAllBtn) {
|
|
rewriteApplyAllBtn.addEventListener('click', () => applyRewriteChanges('all'));
|
|
}
|
|
|
|
const rewriteApplySelectedBtn = document.getElementById('rewriteApplySelectedBtn');
|
|
if (rewriteApplySelectedBtn) {
|
|
rewriteApplySelectedBtn.addEventListener('click', () => applyRewriteChanges('selected'));
|
|
}
|
|
|
|
const caseEmailComposeModal = document.getElementById('caseEmailComposeModal');
|
|
if (caseEmailComposeModal) {
|
|
caseEmailComposeModal.addEventListener('show.bs.modal', () => {
|
|
const statusEl = document.getElementById('caseEmailSendStatus');
|
|
if (statusEl) {
|
|
statusEl.className = 'text-muted';
|
|
statusEl.textContent = '';
|
|
}
|
|
prefillCaseEmailCompose();
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|
});
|
|
}
|
|
|
|
prefillCaseEmailCompose();
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|
loadSagFiles();
|
|
loadLinkedEmails();
|
|
});
|
|
|
|
|