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 = `
${title}
${bodyHtml}
${footerBtns || ''}
`; 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 = `${filename}`; } 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 = `
Henter e-mail...
`; 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(); });