Add dedicated SAG email tab with preview and filters

This commit is contained in:
Christian 2026-03-03 14:33:11 +01:00
parent b80f91fae1
commit 827463d59e

View File

@ -795,6 +795,11 @@
{% endif %} {% endif %}
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="emails-tab" data-bs-toggle="tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails">
<i class="bi bi-envelope me-2"></i>E-mail
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="sales-tab" data-bs-toggle="tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales"> <button class="nav-link" id="sales-tab" data-bs-toggle="tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg <i class="bi bi-basket3 me-2"></i>Varekøb & Salg
@ -1183,23 +1188,27 @@
</div> </div>
</div> </div>
<!-- Linked Emails --> <!-- Email Overview -->
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="card h-100" data-module="emails" data-has-content="unknown"> <div class="card h-100">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📧 Linkede e-mails</h6> <h6 class="mb-0" style="color: var(--accent);">📧 E-mail</h6>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="openCaseEmailTab()">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn e-mail-fane
</button>
</div> </div>
<!-- Email Drop Zone --> <div class="card-body d-flex flex-column justify-content-center">
<div class="card-body p-0 d-flex flex-column" id="emailDropZone"> <div class="text-muted small mb-2">Den nye e-mail-del ligger i en dedikeret fane med flere funktioner.</div>
<div class="p-3 border-bottom bg-light position-relative"> <ul class="small text-muted mb-3 ps-3">
<input type="text" class="form-control form-control-sm" id="emailSearchInput" placeholder="Søg og link e-mail..." autocomplete="off"> <li>Liste over sagens e-mails</li>
<div class="list-group position-absolute shadow-sm" id="emailSearchResults" style="z-index: 1000; display: none; top: 100%; left: 0; right: 0; max-height: 300px; overflow-y: auto;"></div> <li>Preview af e-mail-indhold</li>
</div> <li>Søgning og filtrering</li>
<div class="text-center p-2 small text-muted fst-italic border-bottom"> <li>Vedhæftninger, link/unlink og import</li>
Træk .msg/.eml filer hertil for at importere </ul>
</div> <div>
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="linked-emails-list" style="max-height: 250px;"> <button class="btn btn-primary btn-sm" type="button" onclick="openCaseEmailTab()">
<div class="p-3 text-center text-muted">Ingen e-mails linket...</div> <i class="bi bi-envelope-open me-1"></i>Gå til E-mail
</button>
</div> </div>
</div> </div>
</div> </div>
@ -2941,6 +2950,71 @@
</div> </div>
</div> <!-- End Details Tab --> </div> <!-- End Details Tab -->
<!-- E-mail Tab -->
<div class="tab-pane fade" id="emails" role="tabpanel" tabindex="0" data-module="emails" data-has-content="unknown">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-envelope me-2"></i>E-mail på sagen</h6>
<div class="d-flex gap-2 align-items-center">
<input type="file" id="emailImportInput" accept=".eml,.msg" style="display:none" onchange="if(this.files?.length){ uploadEmailFile(this.files[0]); this.value=''; }">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="document.getElementById('emailImportInput').click()">
<i class="bi bi-cloud-upload me-1"></i>Importér .eml/.msg
</button>
</div>
</div>
<div class="card-body" id="emailDropZone">
<div class="row g-3">
<div class="col-12">
<div class="row g-2">
<div class="col-lg-4 position-relative">
<input type="text" class="form-control form-control-sm" id="emailSearchInput" placeholder="Søg og link e-mail..." autocomplete="off">
<div class="list-group position-absolute shadow-sm" id="emailSearchResults" style="z-index: 1000; display: none; top: 100%; left: 0; right: 0; max-height: 300px; overflow-y: auto;"></div>
</div>
<div class="col-lg-4">
<input type="text" class="form-control form-control-sm" id="emailFilterInput" placeholder="Filtrer i linkede e-mails...">
</div>
<div class="col-lg-2">
<select id="emailAttachmentFilter" class="form-select form-select-sm">
<option value="all">Alle vedhæftninger</option>
<option value="with">Med vedhæftning</option>
<option value="without">Uden vedhæftning</option>
</select>
</div>
<div class="col-lg-2">
<select id="emailReadFilter" class="form-select form-select-sm">
<option value="all">Alle læsestatus</option>
<option value="unread">Ulæste</option>
<option value="read">Læste</option>
</select>
</div>
</div>
<div class="small text-muted fst-italic mt-2">Tip: Træk .msg/.eml fil hertil for at importere direkte på sagen.</div>
</div>
<div class="col-lg-5">
<div class="border rounded h-100 d-flex flex-column">
<div class="p-2 border-bottom d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-secondary">Linkede e-mails</span>
<span class="badge bg-light text-dark border" id="linkedEmailsCount">0</span>
</div>
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="linked-emails-list" style="min-height: 420px; max-height: 65vh;">
<div class="p-3 text-center text-muted">Ingen e-mails linket...</div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="border rounded h-100 d-flex flex-column" id="email-preview-panel" style="min-height: 420px;">
<div class="p-3 text-center text-muted d-flex align-items-center justify-content-center flex-grow-1">
Vælg en e-mail i listen for at se indhold og vedhæftninger
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Solution Tab --> <!-- Solution Tab -->
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}"> <div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}">
<!-- Nextcloud Integration Box --> <!-- Nextcloud Integration Box -->
@ -5809,6 +5883,16 @@
// ---------------- EMAILS ---------------- // ---------------- EMAILS ----------------
let linkedEmailsCache = [];
let selectedLinkedEmailId = null;
function openCaseEmailTab() {
const trigger = document.getElementById('emails-tab');
if (!trigger) return;
const instance = bootstrap.Tab.getOrCreateInstance(trigger);
instance.show();
}
async function loadLinkedEmails() { async function loadLinkedEmails() {
const container = document.getElementById('linked-emails-list'); const container = document.getElementById('linked-emails-list');
if(!container) return; if(!container) return;
@ -5816,8 +5900,16 @@
try { try {
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`); const res = await fetch(`/api/v1/sag/${caseIds}/email-links`);
if(res.ok) { if(res.ok) {
const emails = await res.json(); linkedEmailsCache = await res.json();
renderLinkedEmails(emails); applyLinkedEmailFilters();
if (selectedLinkedEmailId && linkedEmailsCache.some(e => Number(e.id) === Number(selectedLinkedEmailId))) {
await loadLinkedEmailDetail(selectedLinkedEmailId);
} else if (linkedEmailsCache.length > 0) {
await loadLinkedEmailDetail(linkedEmailsCache[0].id);
} else {
selectedLinkedEmailId = null;
renderEmailPreviewEmpty();
}
} else { } else {
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>'; container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
setModuleContentState('emails', true); setModuleContentState('emails', true);
@ -5829,55 +5921,183 @@
} }
} }
function applyLinkedEmailFilters() {
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
const filtered = 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;
});
renderLinkedEmails(filtered);
const counter = document.getElementById('linkedEmailsCount');
if (counter) counter.textContent = String(filtered.length);
}
function renderLinkedEmails(emails) { function renderLinkedEmails(emails) {
const container = document.getElementById('linked-emails-list'); const container = document.getElementById('linked-emails-list');
if (!container) return;
if(!emails || emails.length === 0) { if(!emails || emails.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen linkede e-mails...</div>'; container.innerHTML = '<div class="p-3 text-center text-muted">Ingen linkede e-mails...</div>';
setModuleContentState('emails', false); setModuleContentState('emails', false);
return; return;
} }
setModuleContentState('emails', true); setModuleContentState('emails', true);
const threadMap = new Map(); container.innerHTML = emails.map(e => {
emails.forEach(e => { const isSelected = Number(selectedLinkedEmailId) === Number(e.id);
const key = e.thread_key || `email-${e.id}`; const receivedDate = e.received_date ? new Date(e.received_date).toLocaleString('da-DK') : '-';
if(!threadMap.has(key)) threadMap.set(key, []); const sender = e.sender_name || e.sender_email || '-';
threadMap.get(key).push(e); const subject = e.subject || '(Ingen emne)';
}); 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;
const threads = Array.from(threadMap.values());
container.innerHTML = threads.map((threadEmails, threadIndex) => {
const labelEmail = threadEmails[0];
const messageCount = labelEmail.thread_message_count || threadEmails.length;
return ` return `
<div class="list-group-item p-0"> <button type="button" class="list-group-item list-group-item-action border-0 border-bottom text-start ${isSelected ? 'active' : ''}" onclick="loadLinkedEmailDetail(${e.id})">
<div class="px-3 py-2 border-bottom bg-light d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-start gap-2">
<span class="small fw-semibold text-secondary">Tråd ${threadIndex + 1}</span> <div class="flex-grow-1 overflow-hidden">
<span class="badge bg-primary-subtle text-primary-emphasis">${messageCount} beskeder</span> <div class="fw-semibold text-truncate">${escapeHtml(subject)}</div>
</div> <div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(sender)}</div>
${threadEmails.map(e => ` <div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(snippet || 'Ingen preview')}</div>
<div class="px-3 py-2 border-bottom"> </div>
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex flex-column align-items-end gap-1">
<div class="text-truncate"> <div class="small ${isSelected ? 'text-white-50' : 'text-muted'}">${escapeHtml(receivedDate)}</div>
<i class="bi bi-envelope text-primary me-1"></i> ${hasAttachments ? '<span class="badge bg-info-subtle text-info-emphasis">📎</span>' : ''}
<strong>${e.subject || '(Ingen emne)'}</strong> ${!e.is_read ? '<span class="badge bg-warning text-dark">Ulæst</span>' : ''}
<div class="small text-muted text-truncate">${e.sender_email || '-'}</div> <span class="btn btn-sm btn-link p-0 ${isSelected ? 'text-white' : 'text-danger'}" onclick="event.stopPropagation(); unlinkEmail(${e.id});" title="Fjern link">
</div>
<button class="btn btn-sm btn-link text-danger p-0 ms-2" onclick="unlinkEmail(${e.id})">
<i class="bi bi-link-45deg" style="text-decoration: line-through;"></i> <i class="bi bi-link-45deg" style="text-decoration: line-through;"></i>
</button> </span>
</div> </div>
</div> </div>
`).join('')} </button>
</div>
`; `;
}).join(''); }).join('');
} }
function renderEmailPreviewEmpty() {
const panel = document.getElementById('email-preview-panel');
if (!panel) return;
panel.innerHTML = `
<div class="p-3 text-center text-muted d-flex align-items-center justify-content-center flex-grow-1">
Vælg en e-mail i listen for at se indhold og vedhæftninger
</div>
`;
}
async function loadLinkedEmailDetail(emailId) {
selectedLinkedEmailId = Number(emailId);
const panel = document.getElementById('email-preview-panel');
if (!panel) return;
panel.innerHTML = `
<div class="p-4 text-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Henter e-mail...
</div>
`;
renderLinkedEmails(linkedEmailsCache.filter((email) => {
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
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;
}));
try {
const res = await fetch(`/api/v1/emails/${emailId}`);
if (!res.ok) {
panel.innerHTML = '<div class="p-3 text-danger">Kunne ikke hente e-mail detaljer.</div>';
return;
}
const email = await res.json();
const subject = email.subject || '(Ingen emne)';
const sender = email.sender_name || email.sender_email || '-';
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
const attachments = Array.isArray(email.attachments) ? email.attachments : [];
const bodyText = email.body_text || '';
const bodyHtml = email.body_html || '';
panel.innerHTML = `
<div class="border-bottom p-3">
<div class="fw-bold mb-1">${escapeHtml(subject)}</div>
<div class="small text-muted">Fra: ${escapeHtml(sender)}</div>
<div class="small text-muted">Dato: ${escapeHtml(received)}</div>
</div>
<div class="p-3 border-bottom">
<div class="small fw-semibold mb-2">Vedhæftninger (${attachments.length})</div>
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
</div>
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml ? bodyHtml : '<div class="text-muted">Ingen indhold</div>')}
</div>
`;
const attachmentContainer = document.getElementById('email-attachments-list');
if (attachmentContainer) {
if (!attachments.length) {
attachmentContainer.innerHTML = '<span class="text-muted small">Ingen vedhæftninger</span>';
} else {
attachmentContainer.innerHTML = attachments.map(att => {
const attachmentName = att.filename || `Vedhæftning ${att.id}`;
const url = `/api/v1/emails/${email.id}/attachments/${att.id}`;
return `<a class="btn btn-sm btn-outline-secondary" href="${url}"><i class="bi bi-download me-1"></i>${escapeHtml(attachmentName)}</a>`;
}).join('');
}
}
const cacheIdx = linkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id));
if (cacheIdx >= 0) {
linkedEmailsCache[cacheIdx].is_read = true;
}
} catch (e) {
console.error(e);
panel.innerHTML = '<div class="p-3 text-danger">Fejl ved hentning af e-mail detaljer.</div>';
}
}
async function unlinkEmail(emailId) { async function unlinkEmail(emailId) {
if(!confirm("Fjern link til denne email?")) return; if(!confirm("Fjern link til denne email?")) return;
try { try {
const res = await fetch(`/api/v1/sag/${caseIds}/email-links/${emailId}`, { method: 'DELETE' }); const res = await fetch(`/api/v1/sag/${caseIds}/email-links/${emailId}`, { method: 'DELETE' });
if(res.ok) loadLinkedEmails(); if(res.ok) {
if (Number(selectedLinkedEmailId) === Number(emailId)) {
selectedLinkedEmailId = null;
renderEmailPreviewEmpty();
}
loadLinkedEmails();
}
} catch(e) { alert(e); } } catch(e) { alert(e); }
} }
@ -5905,6 +6125,36 @@
}); });
} }
['emailFilterInput', 'emailAttachmentFilter', 'emailReadFilter'].forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const eventName = id === 'emailFilterInput' ? 'input' : 'change';
el.addEventListener(eventName, () => {
applyLinkedEmailFilters();
const visible = linkedEmailsCache.filter((email) => {
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
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;
});
if (!visible.some((email) => Number(email.id) === Number(selectedLinkedEmailId))) {
renderEmailPreviewEmpty();
}
});
});
async function searchEmails(query) { async function searchEmails(query) {
try { try {
const res = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`); const res = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
@ -5957,6 +6207,13 @@
} }
async function uploadEmailFile(file) { 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(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);