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 = `
`;
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 '';
}
const isTimeModule = actionConfig.moduleKey === 'time';
const isChecked = _isCaseAddModuleEnabled(actionConfig);
return ``;
}
function renderCaseAddActionList(preferredAction = null) {
const listEl = document.getElementById('caseAddModuleList');
if (!listEl) return;
const actions = CASE_ADD_ACTIONS;
if (!actions.length) {
listEl.innerHTML = 'Ingen aktive moduler fundet.
';
return;
}
listEl.innerHTML = actions.map((cfg) => `
${_renderCaseAddModuleToggle(cfg)}
`).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 = 'Indlaeser formular...
';
const relFn = window[action.relFn];
if (typeof relFn !== 'function') {
workspace.innerHTML = 'Modulformular er ikke tilgaengelig endnu.
';
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 = 'Kunne ikke indlaese formularen.
';
}
}
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 = 'Fejl ved søgning
';
} 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 = 'Ingen resultater fundet
';
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 `
`;
}).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 = '' +
stages.map((stage) => ``).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 ``;
}).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 = 'Indlæser...
';
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 `
`;
}).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 = '';
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 ``;
}).join('');
}
async function loadSagFiles() {
const container = document.getElementById('files-list');
if (container) {
container.innerHTML = ' Henter filer...
';
}
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 = 'Fejl ved hentning af filer
';
}
setModuleContentState('files', true);
}
} catch(e) {
console.error(e);
sagFilesCache = [];
updateCaseEmailAttachmentOptions(sagFilesCache);
if (container) {
container.innerHTML = 'Fejl ved hentning af filer
';
}
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 = 'Ingen filer fundet...
';
setModuleContentState('files', false);
return;
}
setModuleContentState('files', true);
container.innerHTML = files.map(f => {
const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB';
return `
${size} • ${new Date(f.created_at).toLocaleDateString()}
`;
}).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 += 'Uploader...
';
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 = `
Indlæser...
`;
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 = `
`;
} else if (ext === 'pdf') {
// PDF preview using iframe
previewContent.innerHTML = ``;
} 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 = `${escapeHtml(text)}
`;
})
.catch(err => {
previewContent.innerHTML = `Kunne ikke indlæse fil: ${err}
`;
});
} 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 = ``;
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
// Video preview
previewContent.innerHTML = `
`;
} else if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) {
// Audio preview
previewContent.innerHTML = `
${filename}
`;
} else {
// Unsupported file type
previewContent.innerHTML = `
Kan ikke vise forhåndsvisning for denne filtype
${filename}
Download fil
`;
}
}
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, ''');
}
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) => `
Før
${escapeHtml(change.before) || '(tom)'}
Efter
${escapeHtml(change.after) || '(tom)'}
`).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 = '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 = 'Fejl ved hentning af emails
';
threadContainer.innerHTML = 'Fejl ved hentning af tråde
';
setModuleContentState('emails', true);
}
} catch(e) {
console.error(e);
container.innerHTML = 'Fejl ved hentning af emails
';
threadContainer.innerHTML = 'Fejl ved hentning af tråde
';
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 = 'Ingen tråde fundet...
';
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 `
`;
}).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 = 'Ingen linkede e-mails...
';
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 `
`;
}).join('');
}
function renderEmailPreviewEmpty() {
const panel = document.getElementById('email-preview-panel');
if (!panel) return;
selectedLinkedEmailDetail = null;
panel.innerHTML = `
Vælg en e-mail i listen for at se indhold og vedhæftninger
`;
}
async function loadLinkedEmailDetail(emailId, skipRefresh = false) {
selectedLinkedEmailId = Number(emailId);
const panel = document.getElementById('email-preview-panel');
if (!panel) return;
panel.innerHTML = `
`;
if (!skipRefresh) {
const threadEmails = getCurrentThreadEmails();
renderLinkedEmails(threadEmails);
}
try {
const res = await fetch(`/api/v1/emails/${emailId}`);
if (!res.ok) {
panel.innerHTML = 'Kunne ikke hente e-mail detaljer.
';
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 = `
${escapeHtml(subject)}
Fra: ${escapeHtml(sender)}
Dato: ${escapeHtml(received)}
Vedhæftninger (${attachments.length})
${bodyText ? `
${escapeHtml(bodyText)}` : (bodyHtml ? bodyHtml : '
Ingen indhold
')}
`;
const attachmentContainer = document.getElementById('email-attachments-list');
if (attachmentContainer) {
if (!attachments.length) {
attachmentContainer.innerHTML = 'Ingen vedhæftninger';
} 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 `${escapeHtml(attachmentName)}`;
}).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 = 'Fejl ved hentning af e-mail detaljer.
';
}
}
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 = 'Ingen fundet
';
return;
}
emailSearchResults.innerHTML = emails.map(e => `
`).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();
});