bmc_hub/app/emails/frontend/emails_v2.html
Christian 1fb5b4a191 Add comprehensive tests for email and drift functionalities
- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements.
- Added tests for email identity API to validate email linking and identity retrieval without updates.
- Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios.
- Created tests for email reply automation to ensure proper routing and processing of replies based on classification.
- Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies.
- Added tests for email sender identity to ensure correct matching of contacts and domains.
- Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2026-11-03 01:16:12 +01:00

2473 lines
110 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}Email - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.emails-v2-shell {
display: grid;
grid-template-columns: 340px minmax(0, 1fr) 420px;
gap: 1rem;
height: calc(100vh - 140px);
min-height: 620px;
}
.emails-v2-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
min-width: 0;
}
.emails-v2-header {
padding: 0.9rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.emails-v2-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.emails-v2-version-nav {
display: flex;
gap: 0.5rem;
}
.emails-v2-search {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--border-color);
}
.emails-quick-upload {
margin: 0.65rem 0.9rem;
padding: 0.55rem 0.7rem;
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 45%, var(--border-color));
border-radius: 9px;
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
text-align: center;
transition: 0.15s ease;
}
.emails-quick-upload:hover,
.emails-quick-upload.dragover {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent, #0f4c75) 9%, var(--bg-card));
}
.emails-quick-upload.busy {
pointer-events: none;
opacity: 0.65;
}
.emails-primary-action {
border: 1px solid color-mix(in srgb, var(--accent, #0f4c75) 35%, var(--border-color));
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-flow-picker {
width: 100%;
}
.emails-v2-flow-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 0.65rem;
}
.emails-v2-flow-choice {
position: relative;
min-height: 100px;
height: 100%;
padding: 0.72rem 0.8rem;
border: 1px solid var(--border-color);
border-radius: 10px;
background: #52606d;
color: #fff;
text-align: left;
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16);
}
.emails-v2-flow-choice:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
}
.emails-v2-flow-choice.recommended {
border-color: var(--accent);
box-shadow: inset 0 0 0 2px var(--accent);
}
.emails-v2-flow-choice.flow-support { background: #1769aa; }
.emails-v2-flow-choice.flow-invoice { background: #18864b; }
.emails-v2-flow-choice.flow-accounting { background: #d97706; }
.emails-v2-flow-choice.flow-archive { background: #59636e; }
.emails-v2-flow-choice.flow-case { background: #1769aa; }
.emails-v2-flow-choice.flow-purchase { background: #59636e; }
.emails-v2-section-heading {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
}
.emails-v2-section-content { padding-top: 0.75rem; }
.emails-v2-flow-choice:disabled {
opacity: 0.42;
box-shadow: none;
}
.emails-v2-flow-choice .flow-title {
display: block;
font-weight: 700;
}
.emails-v2-flow-choice .flow-description {
display: block;
margin-top: 0.22rem;
color: rgba(255, 255, 255, 0.88);
font-size: 0.76rem;
line-height: 1.25;
}
.emails-v2-flow-choice .flow-badge {
position: absolute;
top: -0.55rem;
right: 0.45rem;
padding: 0.12rem 0.42rem;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 0.66rem;
font-weight: 700;
}
@media (max-width: 1500px) {
.emails-v2-flow-grid {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
.email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px;
padding: 0.9rem;
margin-bottom: 0.85rem;
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.email-ai-type {
font-size: 1.15rem;
font-weight: 750;
}
.email-ai-confidence {
height: 7px;
border-radius: 99px;
overflow: hidden;
background: var(--border-color);
}
.email-ai-confidence > span {
display: block;
height: 100%;
background: var(--accent);
}
.email-ai-actions {
margin: 0;
padding-left: 1.2rem;
font-size: 0.86rem;
}
.email-ai-actions li {
margin-bottom: 0.32rem;
}
.emails-shortcuts {
font-size: 0.72rem;
color: var(--text-secondary);
}
.emails-shortcuts kbd {
font-size: 0.68rem;
}
.emails-v2-filters {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
}
.emails-v2-filter {
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
padding: 0.24rem 0.62rem;
}
.emails-v2-filter.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.emails-v2-list {
overflow: auto;
flex: 1;
}
.emails-v2-item {
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 0.9rem;
cursor: pointer;
}
.emails-v2-item:hover {
background: color-mix(in srgb, var(--accent, #0f4c75) 6%, var(--bg-card));
}
.emails-v2-item.active {
background: color-mix(in srgb, var(--accent, #0f4c75) 14%, var(--bg-card));
border-left: 3px solid var(--accent);
padding-left: calc(0.9rem - 3px);
}
.emails-v2-item.unread .subject {
font-weight: 700;
}
.emails-v2-item .head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
.emails-v2-item .sender {
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-item .time {
font-size: 0.74rem;
color: var(--text-secondary);
white-space: nowrap;
}
.emails-v2-item .subject {
margin-top: 0.2rem;
font-size: 0.88rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-item .meta {
margin-top: 0.28rem;
font-size: 0.75rem;
color: var(--text-secondary);
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.emails-v2-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--text-secondary);
}
.emails-v2-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.emails-v2-detail {
flex: 1;
overflow: auto;
padding: 1rem;
}
.emails-v2-mail-header {
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--border-color);
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-quick-toolbar {
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
}
.emails-v2-quick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
.emails-v2-quick-grid .btn {
min-height: 38px;
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-mail-body {
flex: 1;
overflow: auto;
padding: 1rem;
}
.emails-v2-actions-pane {
flex: 1;
overflow: auto;
padding: 1rem;
}
.emails-v2-subject {
font-size: 1.18rem;
font-weight: 700;
margin-bottom: 0.35rem;
word-break: break-word;
}
.emails-v2-topmeta {
color: var(--text-secondary);
font-size: 0.84rem;
margin-bottom: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.emails-v2-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-bottom: 0.95rem;
justify-content: flex-start;
}
.emails-v2-right-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
}
.emails-v2-nextstep {
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 38%, transparent);
border-radius: 10px;
padding: 0.7rem;
margin-bottom: 0.9rem;
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
}
.emails-v2-nextstep .label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 0.3rem;
}
.emails-v2-card {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.emails-v2-card h6 {
margin: 0 0 0.5rem 0;
font-size: 0.82rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.emails-v2-kv {
display: grid;
grid-template-columns: 130px 1fr;
gap: 0.35rem;
font-size: 0.82rem;
margin-bottom: 0.25rem;
}
.emails-v2-kv .k {
color: var(--text-secondary);
font-weight: 600;
}
.emails-v2-kv .v {
color: var(--text-primary);
overflow-wrap: anywhere;
}
.emails-v2-inline-status {
margin-top: 0.55rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.emails-v2-inline-status.error {
color: var(--theme-danger-text, #b42318);
}
.emails-v2-inline-status.success {
color: #067647;
}
.emails-v2-body {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.35;
font-size: 0.92rem;
}
.emails-v2-body.html {
white-space: normal;
line-height: 1.5;
}
.emails-v2-body.html p,
.emails-v2-body.html ul,
.emails-v2-body.html ol,
.emails-v2-body.html blockquote,
.emails-v2-body.html pre,
.emails-v2-body.html table {
margin-bottom: 0.75rem;
}
.emails-v2-body.html table {
width: 100%;
border-collapse: collapse;
}
.emails-v2-body.html th,
.emails-v2-body.html td {
border: 1px solid var(--border-color);
padding: 0.35rem 0.45rem;
vertical-align: top;
}
.emails-v2-body.html a {
word-break: break-all;
}
.emails-v2-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.emails-v2-sag-results {
max-height: 220px;
overflow: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.emails-v2-sag-result {
padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.emails-v2-sag-result:last-child {
border-bottom: none;
}
.emails-v2-sag-result:hover {
background: color-mix(in srgb, var(--accent, #0f4c75) 8%, var(--bg-card));
}
.emails-v2-status {
padding: 0.5rem 0.9rem;
border-top: 1px solid var(--border-color);
font-size: 0.8rem;
color: var(--text-secondary);
}
@media (max-width: 1100px) {
.emails-v2-shell {
grid-template-columns: 1fr;
height: auto;
min-height: 0;
}
.emails-v2-panel {
min-height: 420px;
}
}
@media (min-width: 1101px) and (max-width: 1350px) {
.emails-v2-shell {
grid-template-columns: 310px minmax(0, 1fr);
height: auto;
}
.emails-v2-shell > .emails-v2-panel:last-child {
grid-column: 1 / -1;
min-height: 0;
max-height: 520px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="emails-v2-shell">
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h1 class="emails-v2-title"><i class="bi bi-inbox me-2"></i>Email</h1>
<button id="v2Refresh" class="btn btn-sm btn-outline-primary" title="Opdater (R)">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="emails-v2-search">
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg i mails… (tryk /)">
</div>
<div id="v2Filters" class="emails-v2-filters"></div>
<div class="emails-quick-upload" id="v2UploadZone" role="button" tabindex="0">
<span id="v2UploadLabel">
<i class="bi bi-paperclip me-1"></i>Træk .eml/.msg hertil eller klik
</span>
<input id="v2UploadInput" type="file" accept=".eml,.msg" multiple hidden>
</div>
<div id="v2List" class="emails-v2-list"></div>
<div id="v2ListStatus" class="emails-v2-status">Klar</div>
</section>
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h2 class="emails-v2-title">Detalje</h2>
<span class="emails-shortcuts"><kbd>J</kbd>/<kbd>K</kbd> næste/forrige</span>
</div>
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
<div id="v2QuickActions"></div>
<div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div>
<div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
</section>
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h2 class="emails-v2-title">Handlinger</h2>
<span class="small text-muted">Vælg hvad der skal ske</span>
</div>
<div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
<div id="v2DetailStatus" class="emails-v2-status">Ingen email valgt</div>
</section>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(() => {
const FILTERS = [
{ key: 'active', label: 'Aktive' },
{ key: 'unread', label: 'Ulæste' },
{ key: 'awaiting_user_action', label: 'Afventer handling' },
{ key: 'processed', label: 'Behandlede' },
{ key: 'all', label: 'Alle' },
];
const state = {
emails: [],
selectedEmail: null,
selectedEmailId: null,
workflowPreview: null,
filter: new URLSearchParams(window.location.search).get('filter') === 'unread' ? 'unread' : 'active',
query: '',
folder: '',
offset: 0,
pageSize: 100,
totalCount: null,
hasMore: false,
loadingMore: false,
searchTimer: null,
sagSearchTimer: null,
vendorSuggestion: null,
domainCustomerSuggestion: null,
senderIdentity: null,
senderDraft: null,
senderDraftRequest: 0,
senderSearchTimer: null,
supplierCaseCustomer: null,
supplierCustomerTimer: null,
purchaseMailKind: null,
selectedPurchase: null,
purchaseSearchTimer: null,
queueCounts: null,
};
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatEmailPlainText(value) {
return escapeHtml(value || '').replace(/\n/g, '<br>');
}
function isSafeUrl(url) {
if (!url) return false;
const normalized = String(url).trim().toLowerCase();
return normalized.startsWith('http://')
|| normalized.startsWith('https://')
|| normalized.startsWith('mailto:')
|| normalized.startsWith('tel:')
|| normalized.startsWith('/');
}
function sanitizeEmailHtml(unsafeHtml) {
const input = String(unsafeHtml || '').trim();
if (!input) return '';
const allowedTags = new Set([
'a', 'b', 'strong', 'i', 'em', 'u', 's', 'br', 'p', 'div', 'span',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
]);
const allowedAttrs = {
a: new Set(['href', 'title', 'target', 'rel']),
th: new Set(['colspan', 'rowspan']),
td: new Set(['colspan', 'rowspan']),
};
const parser = new DOMParser();
const doc = parser.parseFromString(input, 'text/html');
const cleanNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return document.createTextNode('');
}
const tag = node.tagName.toLowerCase();
if (!allowedTags.has(tag)) {
const fragment = document.createDocumentFragment();
Array.from(node.childNodes).forEach((child) => {
fragment.appendChild(cleanNode(child));
});
return fragment;
}
const el = document.createElement(tag);
const tagAllowedAttrs = allowedAttrs[tag] || new Set();
Array.from(node.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (!tagAllowedAttrs.has(name)) return;
if (tag === 'a' && name === 'href') {
if (!isSafeUrl(value)) return;
el.setAttribute('href', value);
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
return;
}
if ((name === 'colspan' || name === 'rowspan')) {
const num = Number(value);
if (!Number.isInteger(num) || num < 1 || num > 100) return;
el.setAttribute(name, String(num));
return;
}
el.setAttribute(name, value);
});
Array.from(node.childNodes).forEach((child) => {
el.appendChild(cleanNode(child));
});
return el;
};
const wrapper = document.createElement('div');
Array.from(doc.body.childNodes).forEach((child) => {
wrapper.appendChild(cleanNode(child));
});
return wrapper.innerHTML;
}
function formatDate(value) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString('da-DK');
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, { credentials: 'include', ...options });
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const payload = await response.json();
detail = payload?.detail || detail;
} catch (_) {
const text = await response.text().catch(() => '');
if (text) detail = text;
}
throw new Error(detail);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
return response.text();
}
function setListStatus(message) {
const el = document.getElementById('v2ListStatus');
if (el) el.textContent = message || '';
}
function setDetailStatus(message) {
const el = document.getElementById('v2DetailStatus');
if (el) el.textContent = message || '';
}
function setMailStatus(message) {
const el = document.getElementById('v2MailStatus');
if (el) el.textContent = message || '';
}
function renderFilters() {
const host = document.getElementById('v2Filters');
if (!host) return;
const counts = state.queueCounts || {
active: state.filter === 'active' ? state.emails.length : 0,
unread: state.filter === 'unread' ? state.emails.length : 0,
awaiting_user_action: state.filter === 'awaiting_user_action' ? state.emails.length : 0,
processed: state.filter === 'processed' ? state.emails.length : 0,
all: state.filter === 'all' ? state.emails.length : 0,
};
host.innerHTML = FILTERS.map((f) => {
const active = state.filter === f.key ? 'active' : '';
return `<button class="emails-v2-filter ${active}" data-filter="${f.key}">${escapeHtml(f.label)} (${counts[f.key] || 0})</button>`;
}).join('');
host.querySelectorAll('[data-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
state.filter = btn.getAttribute('data-filter') || 'active';
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
loadEmails();
});
});
}
function renderList() {
const host = document.getElementById('v2List');
if (!host) return;
if (!state.emails.length) {
host.innerHTML = '<div class="emails-v2-empty">Ingen emails matcher dit filter</div>';
return;
}
const rows = state.emails.map((email) => {
const active = Number(email.id) === Number(state.selectedEmailId) ? 'active' : '';
const unread = email.is_read ? '' : 'unread';
const sender = email.sender_name || email.sender_email || 'Ukendt';
const preview = (email.body_text || email.subject || '').slice(0, 90).replace(/\s+/g, ' ').trim();
return `
<article class="emails-v2-item ${active} ${unread}" data-email-id="${Number(email.id)}">
<div class="head">
<div class="sender">${escapeHtml(sender)}</div>
<div class="time">${escapeHtml(formatDate(email.received_date))}</div>
</div>
<div class="subject">${escapeHtml(email.subject || '(Ingen emne)')}</div>
<div class="meta">
<span>Status: ${escapeHtml(email.status || '-')}</span>
<span>Type: ${escapeHtml(email.classification || 'general')}</span>
${email.linked_case_id ? `<span>SAG #${Number(email.linked_case_id)}</span>` : ''}
</div>
${preview ? `<div class="meta">${escapeHtml(preview)}</div>` : ''}
</article>
`;
}).join('');
const more = state.hasMore
? `<div class="p-3 text-center"><button id="v2LoadMore" class="btn btn-sm btn-outline-primary" ${state.loadingMore ? 'disabled' : ''}>${state.loadingMore ? 'Indlæser…' : 'Indlæs flere'}</button></div>`
: '';
host.innerHTML = rows + more;
host.querySelectorAll('[data-email-id]').forEach((node) => {
node.addEventListener('click', () => {
selectEmail(Number(node.getAttribute('data-email-id')));
});
});
document.getElementById('v2LoadMore')?.addEventListener('click', () => loadEmails({append: true}));
}
function emailListUrl(offset = 0) {
let url = `/api/v1/emails?limit=${state.pageSize}&offset=${offset}`;
if (state.filter === 'active') {
url += '&status=new';
} else if (state.filter === 'unread') {
url += '&unread=true';
} else if (state.filter === 'awaiting_user_action') {
url += '&status=awaiting_user_action';
} else if (state.filter === 'processed') {
url += '&status=processed';
}
if (state.query) url += `&q=${encodeURIComponent(state.query)}`;
if (state.folder) url += `&folder=${encodeURIComponent(state.folder)}`;
return url;
}
async function loadEmails({append = false} = {}) {
if (append && (state.loadingMore || !state.hasMore)) return;
const offset = append ? state.offset : 0;
state.loadingMore = append;
setListStatus(append ? 'Indlæser flere emails...' : 'Indlæser emails...');
const host = document.getElementById('v2List');
if (host && !append) {
host.innerHTML = '<div class="emails-v2-empty"><div class="spinner-border spinner-border-sm"></div> Henter...</div>';
}
try {
const [rows, stats, count] = await Promise.all([
apiFetch(emailListUrl(offset)),
apiFetch('/api/v1/emails/queue-counts').catch(() => null),
apiFetch(emailListUrl(0).replace('/emails?', '/emails/count?')).catch(() => null),
]);
const emails = Array.isArray(rows) ? rows : [];
if (stats) state.queueCounts = {
active: Number(stats.active || 0),
unread: Number(stats.unread || 0),
awaiting_user_action: Number(stats.awaiting_user_action || 0),
processed: Number(stats.processed || 0),
all: Number(stats.all || 0),
};
state.totalCount = Number.isFinite(Number(count?.count)) ? Number(count.count) : null;
state.emails = append ? [...state.emails, ...emails] : emails;
state.offset = state.emails.length;
state.hasMore = state.totalCount === null
? emails.length === state.pageSize
: state.offset < state.totalCount;
renderFilters();
renderList();
const totalLabel = state.totalCount === null ? `${state.emails.length}` : `${state.emails.length} af ${state.totalCount}`;
setListStatus(`Viser ${totalLabel} emails`);
} catch (error) {
if (!append) state.emails = [];
renderFilters();
renderList();
setListStatus(`Fejl: ${error.message}`);
} finally {
const wasLoadingMore = state.loadingMore;
state.loadingMore = false;
if (wasLoadingMore) renderList();
}
}
function syncSelectedEmailInList(email) {
const index = state.emails.findIndex((row) => Number(row.id) === Number(email.id));
if (index < 0) return;
state.emails[index] = {...state.emails[index], ...email};
renderList();
}
async function selectEmail(emailId) {
if (!emailId) return;
state.selectedEmailId = Number(emailId);
renderList();
setDetailStatus('Indlæser email...');
try {
const email = await apiFetch(`/api/v1/emails/${emailId}`);
if (state.selectedEmailId !== Number(emailId)) return;
state.selectedEmail = email;
syncSelectedEmailInList(email);
renderDetail(email);
setDetailStatus(`Email #${emailId} indlæst`);
} catch (error) {
setDetailStatus(`Fejl: ${error.message}`);
}
}
async function patchReadState(isRead) {
if (!state.selectedEmailId) return;
try {
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/read-state`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_read: Boolean(isRead) }),
});
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke opdatere læsestatus: ${error.message}`);
}
}
async function archiveCurrent() {
if (!state.selectedEmailId) return;
try {
await apiFetch('/api/v1/emails/bulk/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([state.selectedEmailId]),
});
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
await loadEmails();
setDetailStatus('Email arkiveret');
} catch (error) {
setDetailStatus(`Kunne ikke arkivere email: ${error.message}`);
}
}
async function markProcessedCurrent() {
if (!state.selectedEmailId) return;
try {
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/mark-processed`, {
method: 'POST',
});
await selectEmail(state.selectedEmailId);
setDetailStatus('Email markeret som behandlet');
} catch (error) {
setDetailStatus(`Kunne ikke markere som behandlet: ${error.message}`);
}
}
async function reprocessCurrent() {
if (!state.selectedEmailId) return;
try {
setDetailStatus('Genbehandler email...');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/reprocess`, {
method: 'POST',
});
await selectEmail(state.selectedEmailId);
setDetailStatus('Email genbehandlet');
} catch (error) {
setDetailStatus(`Kunne ikke genbehandle email: ${error.message}`);
}
}
async function executeWorkflowsCurrent() {
if (!state.selectedEmailId) return;
try {
setDetailStatus('Kører workflows...');
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/execute-workflows`, {
method: 'POST',
});
await selectEmail(state.selectedEmailId);
const executed = Number(result?.workflows_executed || result?.executed || 0);
setDetailStatus(`Workflows kørt (${executed})`);
} catch (error) {
setDetailStatus(`Kunne ikke køre workflows: ${error.message}`);
}
}
async function loadWorkflowPreviewCurrent() {
if (!state.selectedEmailId) return;
const emailId = state.selectedEmailId;
try {
const preview = await apiFetch(`/api/v1/emails/${emailId}/workflow-preview`);
if (state.selectedEmailId !== emailId) return;
state.workflowPreview = preview || null;
if (preview?.identity) {
state.domainCustomerSuggestion = preview.identity.customer || {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
state.vendorSuggestion = preview.identity.vendor?.suggestion || {};
renderDomainCustomerSuggestion();
renderVendorSuggestion();
}
renderWorkflowPreview();
} catch (error) {
if (state.selectedEmailId !== emailId) return;
state.workflowPreview = null;
renderWorkflowPreview(`Kunne ikke hente workflow-match: ${error.message}`);
}
}
async function autoRunWorkflowsCurrent() {
if (!state.selectedEmailId) return;
try {
setDetailStatus('Kører autokør...');
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/auto-run-workflows`, {
method: 'POST',
});
await selectEmail(state.selectedEmailId);
const executed = Number(result?.workflows_executed || result?.executed || 0);
setDetailStatus(`Autokør fuldført (${executed})`);
} catch (error) {
setDetailStatus(`Autokør fejlede: ${error.message}`);
}
}
const CLASSIFICATION_LABELS = {
invoice: 'Leverandørfaktura',
freight_note: 'Fragtbrev',
order_confirmation: 'Ordrebekræftelse',
time_confirmation: 'Tidsbekræftelse',
case_notification: 'Sagsnotifikation',
customer_email: 'Kundehenvendelse',
bankruptcy: 'Konkursmeddelelse',
general: 'Almindelig email',
spam: 'Spam',
newsletter: 'Nyhedsbrev',
unknown: 'Ukendt type',
};
const WORKFLOW_ACTION_LABELS = {
create_ticket: 'Opretter en sag/opgave',
link_email_to_ticket: 'Knytter emailen til en eksisterende sag',
route_helpdesk_sag: 'Finder eller opretter den relevante sag',
create_time_entry: 'Opretter en tidsregistrering',
link_to_vendor: 'Knytter emailen til leverandøren',
link_to_customer: 'Knytter emailen til kunden',
extract_invoice_data: 'Sender PDF-bilaget til Leverandørfakturaer → Mangler behandling',
extract_tracking_number: 'Udtrækker trackingnummer',
regex_extract_and_link: 'Finder reference og opretter tilknytning',
process_also_cloud_billing: 'Importerer ALSO-fakturafilen og opretter ordrekladder',
send_slack_notification: 'Sender en Slack-notifikation',
send_email_notification: 'Sender en emailnotifikation',
mark_as_processed: 'Markerer emailen som behandlet',
flag_for_review: 'Markerer emailen til manuel kontrol',
};
async function openSenderCreate(useAi = false) {
const emailId = state.selectedEmailId;
const host = document.getElementById('v2CreateSenderCard');
if (!host) return;
const requestId = ++state.senderDraftRequest;
host.hidden = false;
host.innerHTML = '<h6>Virksomhed og kontakt</h6><div class="small" role="status">' + (useAi ? 'AI læser afsender og signatur…' : 'Henter forslag fra mailen…') + '</div>';
host.scrollIntoView({block: 'nearest', behavior: 'smooth'});
try {
const draft = await apiFetch(`/api/v1/emails/${emailId}/sender-draft?use_ai=${useAi}`);
if (state.selectedEmailId !== emailId || state.senderDraftRequest !== requestId) return;
state.senderDraft = draft;
renderSenderCreateForm();
} catch (error) {
if (state.selectedEmailId !== emailId || state.senderDraftRequest !== requestId) return;
host.innerHTML = `<div class="small text-danger">Forslag kunne ikke hentes: ${escapeHtml(error.message)}</div><button id="v2SenderDraftRetry" class="btn btn-sm btn-outline-primary mt-2">Prøv igen</button>`;
document.getElementById('v2SenderDraftRetry')?.addEventListener('click', () => openSenderCreate(false));
}
}
function renderSenderCreateForm() {
const draft = state.senderDraft;
const host = document.getElementById('v2CreateSenderCard');
if (!host || !draft) return;
const email = state.selectedEmail || {};
const kind = email.supplier_id ? 'vendor' : document.getElementById('v2SenderKind')?.value || 'customer';
const fields = (group, definitions) => definitions.map(([key, label, type]) => `
<label class="small mt-2" for="v2Create-${group}-${key}">${label}</label>
<input id="v2Create-${group}-${key}" data-create-group="${group}" data-create-field="${key}" type="${type || 'text'}" class="form-control form-control-sm" value="${escapeHtml(draft[group]?.[key] || '')}">`).join('');
host.innerHTML = `<form id="v2SenderCreateForm">
<h6>Virksomhed og kontakt</h6>
<div class="small text-muted mb-2">Forslag fra afsender og nyeste signatur. Kontrollér og ret før du gemmer.</div>
${draft.ai_status === 'failed' ? '<div class="small text-warning">AI kunne ikke hente alle oplysninger. Kontrollér forslagene.</div>' : ''}
${draft.ai_status === 'not_requested' ? '<button id="v2SenderDraftAI" class="btn btn-sm btn-outline-primary mb-3" type="button">Find flere oplysninger med AI</button>' : ''}
<label class="small" for="v2CreateKind">Virksomhedstype</label>
<select id="v2CreateKind" class="form-select form-select-sm"><option value="customer" ${kind === 'customer' ? 'selected' : ''}>Kunde</option><option value="vendor" ${kind === 'vendor' ? 'selected' : ''}>Leverandør</option></select>
<label class="small mt-2" for="v2CreateCompanyChoice">Virksomhed</label>
<select id="v2CreateCompanyChoice" class="form-select form-select-sm"></select>
<div id="v2CreateCompanyFields">${fields('company', [['name','Virksomhedsnavn'], ['cvr_number','CVR'], ['phone','Virksomhedens telefon'], ['email','Virksomhedens mail','email'], ['address','Adresse'], ['postal_code','Postnummer'], ['city','By'], ['website','Hjemmeside']])}</div>
<hr><label class="small" for="v2CreateContactChoice">Kontaktperson</label>
<select id="v2CreateContactChoice" class="form-select form-select-sm">
<option value="">Opret ny kontakt</option>
${(draft.contacts || []).map(row => `<option value="${Number(row.id)}">Brug ${escapeHtml(row.first_name)} ${escapeHtml(row.last_name)} (${escapeHtml(row.email)})</option>`).join('')}
</select>
<div id="v2CreateContactFields">${fields('contact', [['first_name','Fornavn'], ['last_name','Efternavn'], ['email','Kontaktens mail','email'], ['phone','Direkte telefon'], ['mobile','Mobil'], ['title','Stilling'], ['department','Afdeling']])}</div>
<div class="small text-muted mt-2">Eksisterende oplysninger overskrives ikke. Der gemmes ingen domæneregel.</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button id="v2SenderCreateSave" class="btn btn-primary btn-sm" type="submit">Gem virksomhed og kontakt</button>
<button id="v2SenderCreateCancel" class="btn btn-outline-secondary btn-sm" type="button">Annuller</button>
</div>
<div id="v2SenderCreateStatus" class="small mt-2" role="status"></div>
</form>`;
const updateCompanies = () => {
const kind = document.getElementById('v2CreateKind').value;
const rows = (draft.companies || []).filter(row => row.kind === kind);
const linkedId = kind === 'customer' ? email.customer_id : email.supplier_id;
const linkedName = kind === 'customer' ? email.customer_name : email.supplier_name;
if (linkedId && !rows.some(row => Number(row.id) === Number(linkedId))) rows.push({id: linkedId, name: linkedName || 'Allerede tilknyttet'});
const select = document.getElementById('v2CreateCompanyChoice');
select.innerHTML = '<option value="">Opret ny virksomhed</option>' + rows.map(row => `<option value="${Number(row.id)}">Brug eksisterende: ${escapeHtml(row.name)}</option>`).join('');
if (linkedId) select.value = String(linkedId);
else if (rows.length === 1) select.value = String(rows[0].id);
updateFields();
};
const updateFields = () => {
document.getElementById('v2CreateCompanyFields').hidden = Boolean(document.getElementById('v2CreateCompanyChoice').value);
document.getElementById('v2CreateContactFields').hidden = Boolean(document.getElementById('v2CreateContactChoice').value);
};
if ((draft.contacts || []).length === 1) document.getElementById('v2CreateContactChoice').value = String(draft.contacts[0].id);
document.getElementById('v2CreateKind').addEventListener('change', updateCompanies);
document.getElementById('v2CreateCompanyChoice').addEventListener('change', updateFields);
document.getElementById('v2CreateContactChoice').addEventListener('change', updateFields);
document.getElementById('v2SenderCreateCancel').addEventListener('click', () => {host.hidden = true;});
document.getElementById('v2SenderDraftAI')?.addEventListener('click', () => openSenderCreate(true));
document.getElementById('v2SenderCreateForm').addEventListener('submit', submitSenderCreate);
updateCompanies();
}
async function submitSenderCreate(event) {
event.preventDefault();
const emailId = state.selectedEmailId;
const button = document.getElementById('v2SenderCreateSave');
if (button.disabled) return;
const payload = {kind: document.getElementById('v2CreateKind').value, company: {}, contact: {}};
document.querySelectorAll('[data-create-group]').forEach(input => {
payload[input.dataset.createGroup][input.dataset.createField] = input.value.trim() || null;
});
payload.company.existing_id = Number(document.getElementById('v2CreateCompanyChoice').value) || null;
payload.contact.existing_id = Number(document.getElementById('v2CreateContactChoice').value) || null;
// Blank names are valid for reused records, but new records need explicit names.
for (const [group, keys] of [['company', ['name']], ['contact', ['first_name','last_name','email']]])
keys.forEach(key => {payload[group][key] ||= '';});
const status = document.getElementById('v2SenderCreateStatus');
button.disabled = true;
status.textContent = 'Gemmer virksomhed og kontakt…';
try {
const result = await apiFetch(`/api/v1/emails/${emailId}/create-sender`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
});
if (state.selectedEmailId !== emailId) return;
await selectEmail(emailId);
setDetailStatus(result.reused_contact ? 'Virksomheden er tilknyttet den eksisterende kontakt' : 'Virksomhed og kontakt er gemt og tilknyttet mailen');
} catch (error) {
if (state.selectedEmailId !== emailId) return;
button.disabled = false;
status.textContent = `Kunne ikke gemme: ${error.message}`;
}
}
function simplifySideSections(host) {
// All sidebar sections are permanently expanded; headings are not controls.
[...host.querySelectorAll('details')].reverse().forEach(details => {
const summary = details.querySelector(':scope > summary');
if (!summary) return;
const section = document.createElement('section');
section.className = details.className;
const heading = document.createElement('h6');
heading.className = 'emails-v2-section-heading';
heading.textContent = summary.textContent.trim().replace(/^Vis /i, '');
const content = document.createElement('div');
content.className = 'emails-v2-section-content';
[...details.childNodes].filter(node => node !== summary).forEach(node => content.appendChild(node));
section.append(heading, content);
details.replaceWith(section);
});
}
function renderIdentityAssessment() {
const host = document.getElementById('v2SenderIdentity');
if (!host) return;
const email = state.selectedEmail || {};
const identity = state.senderIdentity;
const labels = {contact_email: 'Mailadresse matcher kontakt', exact_email: 'Mailadresse matcher virksomheden',
saved_domain: 'Gemt domænetilknytning', exact_domain: 'Domænet matcher virksomheden'};
const candidates = identity?.candidates || [];
const current = [email.customer_id ? `Kunde: ${email.customer_name || '#' + email.customer_id}` : '',
email.supplier_id ? `Leverandør: ${email.supplier_name || '#' + email.supplier_id}` : ''].filter(Boolean);
host.innerHTML = `
<h6>Afsender</h6>
<div class="fw-semibold">${escapeHtml(email.sender_name || identity?.address || email.sender_email)}</div>
<div class="small text-break mb-2">${escapeHtml(identity?.address || email.sender_email)}</div>
${current.length ? `<div class="small text-success mb-2">${current.map(escapeHtml).join('<br>')}</div>` : ''}
${!identity ? '<div class="small text-muted">Finder afsender…</div>' : identity.error
? '<div class="small text-danger">Afsender kunne ikke genkendes. Søg manuelt nedenfor.</div>'
: !current.length && !candidates.length ? '<div class="small text-muted">Ingen kendt afsender. Søg efter en eksisterende kunde eller leverandør.</div>' : ''}
${!current.length && identity?.ambiguous ? '<div class="small text-warning mb-2">Flere mulige tilknytninger — vælg den relevante.</div>' : ''}
${!current.length ? candidates.map(row => `
<div class="border rounded p-2 mt-2">
<div class="fw-semibold small">${escapeHtml(row.name)}</div>
<div class="small text-muted">${row.kind === 'vendor' ? 'Leverandør' : 'Kunde'} · ${escapeHtml(labels[row.source] || row.source)}</div>
${row.contact_name ? `<div class="small">Kontakt: ${escapeHtml(row.contact_name)}</div>` : ''}
<button class="btn btn-sm btn-outline-primary mt-1" data-sender-kind="${escapeHtml(row.kind)}" data-sender-id="${Number(row.id)}">Tilknyt</button>
</div>`).join('') : ''}
<div class="mt-3">
<div class="small fw-semibold">${current.length ? 'Skift tilknytning' : 'Søg kunde eller leverandør'}</div>
<label class="small mt-2" for="v2SenderKind">Søg blandt</label>
<select id="v2SenderKind" class="form-select form-select-sm"><option value="customer">Kunder</option><option value="vendor">Leverandører</option></select>
<input id="v2SenderSearch" class="form-control form-control-sm mt-2" placeholder="Navn, mail, domæne eller CVR" aria-label="Søg afsender">
<div id="v2SenderResults" class="mt-2"></div>
</div>
<button id="v2OpenSenderCreate" class="btn btn-sm btn-primary mt-3">Opret kunde / leverandør + kontakt</button>
<div id="v2SenderError" class="small text-danger mt-2" role="status"></div>`;
bindIdentityDecisionActions();
}
function bindIdentityDecisionActions() {
const host = document.getElementById('v2SenderIdentity');
host?.querySelectorAll('[data-sender-id]').forEach(button => {
button.addEventListener('click', () => linkSenderIdentity(button.dataset.senderKind, Number(button.dataset.senderId), button));
});
document.getElementById('v2OpenSenderCreate')?.addEventListener('click', () => openSenderCreate(false));
const search = document.getElementById('v2SenderSearch');
const scheduleSearch = () => {
clearTimeout(state.senderSearchTimer);
state.senderSearchTimer = setTimeout(searchSenderIdentity, 220);
};
search?.addEventListener('input', scheduleSearch);
document.getElementById('v2SenderKind')?.addEventListener('change', scheduleSearch);
}
async function loadSenderIdentity() {
const emailId = state.selectedEmailId;
state.senderIdentity = null;
renderIdentityAssessment();
try {
const result = await apiFetch(`/api/v1/emails/${emailId}/sender-identity`);
if (state.selectedEmailId !== emailId) return;
state.senderIdentity = result;
} catch (error) {
if (state.selectedEmailId !== emailId) return;
state.senderIdentity = {error: true};
}
renderIdentityAssessment();
const email = state.selectedEmail || {};
const candidates = state.senderIdentity?.candidates || [];
if (!email.customer_id && !email.supplier_id && candidates.length === 1
&& candidates[0].source === 'contact_email') {
// Only an exact, unambiguous existing contact relation is automatic.
// Domain matches and conflicting company relations stay suggestions.
await linkSenderIdentity(candidates[0].kind, Number(candidates[0].id), {disabled: false});
}
}
async function searchSenderIdentity() {
const emailId = state.selectedEmailId;
const input = document.getElementById('v2SenderSearch');
const kindInput = document.getElementById('v2SenderKind');
const query = input?.value.trim() || '';
const kind = kindInput?.value;
const host = document.getElementById('v2SenderResults');
if (!host) return;
host.textContent = query.length < 2 ? 'Skriv mindst to tegn.' : 'Søger…';
if (query.length < 2) return;
try {
const url = kind === 'vendor' ? `/api/v1/vendors?search=${encodeURIComponent(query)}`
: `/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`;
const rows = await apiFetch(url);
if (state.selectedEmailId !== emailId || document.getElementById('v2SenderSearch') !== input
|| input.value.trim() !== query || kindInput.value !== kind) return;
host.innerHTML = rows.length ? rows.map(row => `
<button class="btn btn-sm btn-outline-secondary w-100 text-start mb-1" data-sender-kind="${kind}" data-sender-id="${Number(row.id)}">${escapeHtml(row.name)}${row.cvr_number ? ` · ${escapeHtml(row.cvr_number)}` : ''}</button>`).join('')
: '<div class="small text-muted">Ingen fundet. Prøv et andet navn eller CVR.</div>';
host.querySelectorAll('[data-sender-id]').forEach(button => button.addEventListener('click',
() => linkSenderIdentity(kind, Number(button.dataset.senderId), button)));
} catch (error) {
if (document.getElementById('v2SenderSearch') === input && input.value.trim() === query && kindInput.value === kind)
host.textContent = 'Søgningen fejlede. Prøv igen.';
}
}
async function linkSenderIdentity(kind, id, button) {
const emailId = state.selectedEmailId;
button.disabled = true;
try {
await apiFetch(`/api/v1/emails/${emailId}/link`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({[kind === 'vendor' ? 'supplier_id' : 'customer_id']: id, remember_domain: false}),
});
if (state.selectedEmailId === emailId) await selectEmail(emailId);
} catch (error) {
if (state.selectedEmailId !== emailId) return;
button.disabled = false;
const host = document.getElementById('v2SenderError');
if (host) host.textContent = `Kunne ikke tilknytte: ${error.message}`;
}
}
function renderClassificationDecision(errorMessage) {
const host = document.getElementById('v2DecisionCard');
if (!host) return;
if (errorMessage) {
host.innerHTML = `
<div class="fw-semibold text-danger">Vurdering kunne ikke indlæses</div>
<div class="small text-muted mt-1">${escapeHtml(errorMessage)}</div>
`;
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Systemet vurderer mailen…</div>';
return;
}
const meta = preview.email || {};
const classification = String(meta.classification || 'unknown').toLowerCase();
const typeLabel = CLASSIFICATION_LABELS[classification] || classification;
const rawConfidence = Number(meta.confidence_score || 0);
const confidencePercent = Math.round(Math.max(0, Math.min(1, rawConfidence)) * 100);
const confidenceLabel = confidencePercent >= 80
? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed – kontrollér manuelt';
const email = state.selectedEmail || {};
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
const effects = [];
if (isApprovedSupplierInvoice) {
effects.push(
'Beholder tilknytningen til den valgte leverandør',
'Henter PDF-bilaget og sender det til Leverandørfakturaer',
'Aflæser fakturanummer, beløb og varelinjer',
'Opdaterer Internetforbindelser automatisk, når fakturaen er fra GlobalConnect',
'Markerer emailen som behandlet'
);
} else {
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
(preview.matching_workflows || []).forEach((workflow) => {
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
actions.forEach((action) => effects.push(
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
));
});
}
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Mailtype</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
</div>
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Behandlingen vælges under Handling.</div>
<details class="mt-2">
<summary class="small">Vis vurdering og handlinger</summary>
<div class="mt-3">
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-2">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : ''}
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
</div>
</details>
`;
simplifySideSections(host);
}
async function approveSuggestedActions() {
const button = document.getElementById('v2ApproveActions');
if (!button || !state.selectedEmailId) return;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try {
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
if (classification === 'invoice' && state.selectedEmail?.supplier_id) {
await runQuickAction('create_supplier_invoice', button);
} else {
await executeWorkflowsCurrent();
}
} finally {
if (document.body.contains(button)) {
button.disabled = false;
button.innerHTML = '<i class="bi bi-check-circle me-2"></i>Godkend og udfør';
}
}
}
function renderWorkflowPreview(errorMessage) {
const host = document.getElementById('v2WorkflowPreview');
if (!host) return;
if (errorMessage) {
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
renderClassificationDecision(errorMessage);
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
renderClassificationDecision();
return;
}
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
const emailMeta = preview.email || {};
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
&& Boolean(state.selectedEmail?.supplier_id);
const systemHtml = system.map((row) => {
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
return `
<div class="emails-v2-kv">
<div class="k">${escapeHtml(row.name || row.code || 'System')}</div>
<div class="v">${badge} <span class="small text-muted">${escapeHtml(row.reason || '')}</span></div>
</div>
`;
}).join('');
const matchingHtml = usesSupplierApprovalFlow
? `<div class="emails-v2-kv">
<div class="k">Godkendelsesflow</div>
<div class="v">
<span class="badge bg-success">Leverandørfaktura</span>
<span class="small text-muted">PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet</span>
</div>
</div>`
: matching.length
? matching.map((wf) => `
<div class="emails-v2-kv">
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
<div class="v">
<span class="badge bg-success">Matcher</span>
<span class="small text-muted">prio ${escapeHtml(String(wf.priority ?? '-'))} • min conf ${escapeHtml(String(wf.confidence_threshold ?? '-'))} • steps ${escapeHtml(String(wf.steps_total ?? 0))}</span>
</div>
</div>
`).join('')
: '<div class="small text-muted">Ingen bruger-workflows matcher aktuelt.</div>';
host.innerHTML = `
<div class="emails-v2-kv"><div class="k">Klassifikation</div><div class="v">${escapeHtml(emailMeta.classification || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Confidence</div><div class="v">${escapeHtml(String(emailMeta.confidence_score ?? 0))}</div></div>
<div class="mt-2">
<div class="small fw-semibold mb-1">Systemflows</div>
${systemHtml || '<div class="small text-muted">Ingen systemflows</div>'}
</div>
<div class="mt-2">
<div class="small fw-semibold mb-1">Workflow match</div>
${matchingHtml}
</div>
`;
renderClassificationDecision();
const autoRunBtn = document.getElementById('v2AutoRun');
if (autoRunBtn) {
const enabled = Boolean(preview.auto_run_enabled);
autoRunBtn.disabled = !enabled;
autoRunBtn.title = enabled
? 'Autokør er aktiv'
: 'Aktiveres senere via EMAIL_WORKFLOW_AUTORUN_ENABLED=true';
}
}
async function autoParseAndLinkCurrent() {
if (!state.selectedEmailId) return;
try {
setDetailStatus('Auto parser email til tråd/sag...');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/execute-workflows`, {
method: 'POST',
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/reprocess`, {
method: 'POST',
});
await selectEmail(state.selectedEmailId);
setDetailStatus('Auto parse gennemført');
} catch (error) {
setDetailStatus(`Auto parse fejlede: ${error.message}`);
}
}
function openPurchaseMail(kind) {
state.purchaseMailKind = kind;
state.selectedPurchase = null;
const form = document.getElementById('v2PurchaseMailForm');
form.hidden = false;
document.getElementById('v2PurchaseMailTitle').textContent = kind === 'freight_note' ? 'Levering / tracking' : 'Ordrebekræftelse';
document.getElementById('v2PurchaseTrackingField').hidden = kind !== 'freight_note';
document.getElementById('v2PurchaseTracking').value = '';
document.getElementById('v2PurchaseSearch').value = '';
document.getElementById('v2PurchaseSelected').textContent = '';
document.getElementById('v2PurchaseStatus').textContent = '';
document.getElementById('v2PurchaseSubmit').disabled = true;
form.scrollIntoView({block: 'nearest', behavior: 'smooth'});
document.getElementById('v2PurchaseSearch').focus();
searchPurchaseOptions();
}
async function searchPurchaseOptions() {
const emailId = state.selectedEmailId;
const input = document.getElementById('v2PurchaseSearch');
const host = document.getElementById('v2PurchaseOptions');
if (!input || !host) return;
const query = input.value.trim();
host.textContent = 'Søger…';
try {
const rows = await apiFetch(`/api/v1/emails/purchase-options?q=${encodeURIComponent(query)}`);
if (state.selectedEmailId !== emailId || document.getElementById('v2PurchaseSearch') !== input || input.value.trim() !== query) return;
host.innerHTML = rows.length ? rows.map(row => `<button type="button" class="btn btn-outline-secondary btn-sm w-100 text-start mb-2" data-purchase-id="${Number(row.id)}">
<strong>${escapeHtml(row.description)}</strong><br>
<span class="small">${escapeHtml(row.customer_name || 'Ukendt kunde')} · Sag #${Number(row.sag_id)} · ${escapeHtml(row.case_title)}</span>
${row.external_ref ? `<br><span class="small">Reference: ${escapeHtml(row.external_ref)}</span>` : ''}</button>`).join('')
: '<div class="small text-muted">Ingen bestillinger fundet. Prøv varenavn, reference eller kunde.</div>';
host.querySelectorAll('[data-purchase-id]').forEach(button => button.addEventListener('click', () => {
const row = rows.find(item => Number(item.id) === Number(button.dataset.purchaseId));
state.selectedPurchase = row;
document.getElementById('v2PurchaseSelected').textContent = `Valgt: ${row.description} — ${row.customer_name || 'Ukendt kunde'} · Sag #${row.sag_id}`;
document.getElementById('v2PurchaseSubmit').disabled = false;
}));
} catch (error) {
if (document.getElementById('v2PurchaseSearch') === input) host.textContent = 'Bestillinger kunne ikke hentes. Prøv igen.';
}
}
async function submitPurchaseMail(event) {
event.preventDefault();
const emailId = state.selectedEmailId;
const button = document.getElementById('v2PurchaseSubmit');
const status = document.getElementById('v2PurchaseStatus');
if (!state.selectedPurchase || button.disabled) return;
const payload = {purchase_id: Number(state.selectedPurchase.id), kind: state.purchaseMailKind,
tracking_number: state.purchaseMailKind === 'freight_note' ? document.getElementById('v2PurchaseTracking').value.trim() || null : null};
button.disabled = true;
status.textContent = 'Knytter mailen til bestillingen…';
try {
const result = await apiFetch(`/api/v1/emails/${emailId}/purchase-link`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
});
if (state.selectedEmailId !== emailId) return;
await selectEmail(emailId);
setDetailStatus(result.message);
} catch (error) {
if (state.selectedEmailId !== emailId) return;
status.textContent = error.message;
button.disabled = false;
}
}
function openCaseLinkPicker() {
const picker = document.getElementById('v2CaseLinkPicker');
const input = document.getElementById('v2SagSearch');
if (!picker || !input) return;
picker.scrollIntoView({block: 'nearest', behavior: 'smooth'});
input.focus();
}
function openSupplierCase() {
const form = document.getElementById('v2SupplierCaseForm');
if (!form) return;
form.hidden = false;
form.scrollIntoView({block: 'nearest', behavior: 'smooth'});
document.getElementById(state.supplierCaseCustomer ? 'v2SupplierCaseTitle' : 'v2SupplierCaseCustomer')?.focus();
}
async function searchSupplierCaseCustomer() {
const emailId = state.selectedEmailId;
const input = document.getElementById('v2SupplierCaseCustomer');
const host = document.getElementById('v2SupplierCaseCustomers');
const query = input?.value.trim() || '';
if (!host) return;
host.textContent = query.length < 2 ? 'Skriv mindst to tegn.' : 'Søger…';
if (query.length < 2) return;
try {
const rows = await apiFetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
if (state.selectedEmailId !== emailId || document.getElementById('v2SupplierCaseCustomer') !== input || input.value.trim() !== query) return;
host.innerHTML = rows.length ? rows.map(row => `<button type="button" class="btn btn-sm btn-outline-secondary w-100 text-start mb-1" data-customer-id="${Number(row.id)}">${escapeHtml(row.name)}${row.cvr_number ? ` · ${escapeHtml(row.cvr_number)}` : ''}</button>`).join('')
: '<div class="small text-muted">Ingen kunder fundet.</div>';
host.querySelectorAll('[data-customer-id]').forEach(button => button.addEventListener('click', () => {
const row = rows.find(item => Number(item.id) === Number(button.dataset.customerId));
state.supplierCaseCustomer = {id: Number(row.id), name: row.name};
input.value = row.name;
host.innerHTML = '';
document.getElementById('v2SupplierCaseSubmit').disabled = false;
}));
} catch (error) {
if (state.selectedEmailId === emailId && document.getElementById('v2SupplierCaseCustomer') === input)
host.textContent = 'Søgningen fejlede. Prøv igen.';
}
}
async function createSupplierCase(event) {
event.preventDefault();
const emailId = state.selectedEmailId;
const button = document.getElementById('v2SupplierCaseSubmit');
const status = document.getElementById('v2SupplierCaseStatus');
if (!emailId || !state.selectedEmail?.supplier_id || !state.supplierCaseCustomer || button?.disabled) return;
const title = document.getElementById('v2SupplierCaseTitle').value.trim();
if (!title) {
status.textContent = 'Skriv en titel til sagen.';
return;
}
const contact = state.senderIdentity?.candidates?.find(row => row.kind === 'vendor'
&& Number(row.id) === Number(state.selectedEmail.supplier_id) && row.source === 'contact_email');
button.disabled = true;
status.textContent = 'Opretter sag og tilknytter mail…';
try {
const result = await apiFetch(`/api/v1/emails/${emailId}/create-sag`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({titel: title, case_type: 'leverandor',
customer_id: state.supplierCaseCustomer.id, contact_id: contact?.contact_id || null}),
});
if (state.selectedEmailId !== emailId) return;
await selectEmail(emailId);
setDetailStatus(`Leverandørsag #${Number(result?.sag?.id)} oprettet`);
} catch (error) {
if (state.selectedEmailId !== emailId) return;
status.textContent = `Sagen kunne ikke oprettes: ${error.message}`;
button.disabled = false;
}
}
async function createCaseFromCurrent() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const caseTypeEl = document.getElementById('v2CaseType');
const titelEl = document.getElementById('v2CaseTitle');
const createButton = document.getElementById('v2CreateCase');
const payload = {
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
case_type: String(caseTypeEl?.value || 'support'),
};
if (state.selectedEmail.customer_id) {
payload.customer_id = Number(state.selectedEmail.customer_id);
}
try {
if (createButton) {
createButton.disabled = true;
createButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter…';
}
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const sagId = Number(result?.sag?.id || result?.sag_id || 0);
setDetailStatus(sagId ? `SAG #${sagId} oprettet` : 'SAG oprettet');
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette sag: ${error.message}`);
window.alert(`Sagen kunne ikke oprettes:\n${error.message}`);
} finally {
if (createButton && document.body.contains(createButton)) {
createButton.disabled = false;
createButton.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Opret';
}
}
}
async function runQuickAction(action, button) {
if (!state.selectedEmailId || !state.selectedEmail || !action) return;
const originalHtml = button?.innerHTML || '';
try {
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Udfører…';
}
const title = String(document.getElementById('v2CaseTitle')?.value
|| state.selectedEmail.subject
|| '').trim();
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/quick-action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, titel: title }),
});
if (action === 'archive_entity_mail') {
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
await loadEmails();
} else {
await selectEmail(state.selectedEmailId);
}
setDetailStatus(result?.message || 'Handlingen er udført');
} catch (error) {
setDetailStatus(`Handlingen fejlede: ${error.message}`);
window.alert(`Handlingen kunne ikke udføres:\n${error.message}`);
} finally {
if (button && document.body.contains(button)) {
button.disabled = false;
button.innerHTML = originalHtml;
}
}
}
async function linkSelectedSag(sagId) {
if (!state.selectedEmailId || !sagId) return;
try {
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sag_id: Number(sagId), relation_type: 'mail', mark_processed: true }),
});
setDetailStatus(`Email linket til SAG #${sagId}`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke linke sag: ${error.message}`);
}
}
function setSupplierStatus(message, type) {
const el = document.getElementById('v2SupplierStatus');
if (!el) return;
el.className = `emails-v2-inline-status ${type || ''}`.trim();
el.textContent = message || '';
}
function renderVendorSuggestion() {
const host = document.getElementById('v2VendorSuggestion');
if (!host) return;
const suggestion = state.vendorSuggestion;
if (!suggestion) {
host.innerHTML = '<div class="small text-muted">Ingen forslag endnu</div>';
return;
}
host.innerHTML = `
<div class="emails-v2-kv"><div class="k">Navn</div><div class="v">${escapeHtml(suggestion.name || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(suggestion.cvr_number || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Adresse</div><div class="v">${escapeHtml(suggestion.address || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Telefon</div><div class="v">${escapeHtml(suggestion.phone || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Email</div><div class="v">${escapeHtml(suggestion.email || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Domæne</div><div class="v">${escapeHtml(suggestion.domain || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Match</div><div class="v">Vendor ID: ${escapeHtml(String(suggestion.vendor_id || '-'))} • Score: ${escapeHtml(String(suggestion.match_score || 0))}</div></div>
`;
}
async function extractVendorSuggestionCurrent() {
if (!state.selectedEmailId) return;
try {
setSupplierStatus('Udtrækker leverandørforslag...');
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/extract-vendor-suggestion`, {
method: 'POST',
});
state.vendorSuggestion = suggestion || null;
renderVendorSuggestion();
renderClassificationDecision();
setSupplierStatus('Forslag opdateret', 'success');
} catch (error) {
state.vendorSuggestion = {};
renderClassificationDecision();
setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error');
}
}
function setDomainStatus(message, type) {
const el = document.getElementById('v2DomainStatus');
if (!el) return;
el.className = `emails-v2-inline-status ${type || ''}`.trim();
el.textContent = message || '';
}
function renderDomainCustomerSuggestion() {
const host = document.getElementById('v2DomainSuggestion');
if (!host) return;
const data = state.domainCustomerSuggestion;
if (!data) {
host.innerHTML = '<div class="small text-muted">Ingen domæneforslag endnu</div>';
return;
}
if (data.has_customer) {
host.innerHTML = '<div class="small text-success">Email har allerede kunde-link.</div>';
return;
}
if (data.ignored) {
host.innerHTML = `<div class="small text-muted">Forslag ignoreret (${escapeHtml(data.reason || 'ukendt')})</div>`;
return;
}
if (!data.suggestion) {
host.innerHTML = '<div class="small text-muted">Ingen match på domæne</div>';
return;
}
host.innerHTML = `
<div class="emails-v2-kv"><div class="k">Domæne</div><div class="v">${escapeHtml(data.domain || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Kunde</div><div class="v">${escapeHtml(data.suggestion.customer_name || '-')} (#${escapeHtml(String(data.suggestion.customer_id || '-'))})</div></div>
<div class="emails-v2-kv"><div class="k">Sikkerhed</div><div class="v">${escapeHtml(data.suggestion.confidence || '-')} • score ${escapeHtml(String(data.suggestion.score || 0))}</div></div>
<div class="emails-v2-kv"><div class="k">Kilde</div><div class="v">${escapeHtml(data.suggestion.source || '-')}</div></div>
<button id="v2ApplyDomainSuggestion" class="btn btn-sm btn-outline-primary mt-2">Anvend kunde-link</button>
`;
document.getElementById('v2ApplyDomainSuggestion')?.addEventListener('click', applyDomainCustomerSuggestion);
}
async function loadDomainCustomerSuggestionCurrent() {
if (!state.selectedEmailId) return;
try {
setDomainStatus('Henter domæneforslag...');
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`);
state.domainCustomerSuggestion = suggestion || null;
renderDomainCustomerSuggestion();
renderClassificationDecision();
setDomainStatus('Forslag opdateret', 'success');
} catch (error) {
state.domainCustomerSuggestion = {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
renderClassificationDecision();
setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error');
}
}
async function applyDomainCustomerSuggestion() {
if (!state.selectedEmailId) return;
const suggestion = state.domainCustomerSuggestion?.suggestion;
if (!suggestion?.customer_id) return;
try {
setDomainStatus('Anvender kunde-link...');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: Number(suggestion.customer_id) }),
});
await selectEmail(state.selectedEmailId);
setDomainStatus('Kunde-link anvendt', 'success');
} catch (error) {
setDomainStatus(`Kunne ikke anvende forslag: ${error.message}`, 'error');
}
}
async function createCustomerFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const domain = state.domainCustomerSuggestion?.domain
|| String(email.sender_email || '').split('@')[1]
|| null;
const name = String(email.sender_name || domain || '').trim();
if (!name) {
setDetailStatus('Kunden kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter kunden ${name}…`);
const customer = await apiFetch('/api/v1/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email: email.sender_email || null,
email_domain: domain,
is_active: true,
country: 'DK',
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: Number(customer.id) }),
});
setDetailStatus(`Kunden ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette kunden: ${error.message}`);
}
}
async function linkVendorSuggestion() {
const vendorId = Number(state.vendorSuggestion?.vendor_id || 0);
if (!state.selectedEmailId || !vendorId) return;
try {
setDetailStatus('Tilknytter leverandøren…');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: vendorId }),
});
setDetailStatus('Leverandøren blev tilknyttet');
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke tilknytte leverandøren: ${error.message}`);
}
}
async function createVendorFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const suggestion = state.vendorSuggestion || {};
const senderDomain = String(email.sender_email || '').split('@')[1] || null;
const name = String(suggestion.name || email.sender_name || senderDomain || '').trim();
if (!name) {
setDetailStatus('Leverandøren kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter leverandøren ${name}…`);
const vendor = await apiFetch('/api/v1/vendors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
cvr_number: suggestion.cvr_number || null,
email: suggestion.email || email.sender_email || null,
phone: suggestion.phone || null,
address: suggestion.address || null,
domain: suggestion.domain || senderDomain,
category: 'supplier',
notes: `Oprettet fra email #${state.selectedEmailId}`,
is_active: true,
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: Number(vendor.id) }),
});
setDetailStatus(`Leverandøren ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette leverandøren: ${error.message}`);
}
}
async function searchSager(query) {
const emailId = state.selectedEmailId;
const host = document.getElementById('v2SagResults');
if (!host) return;
const q = String(query || '').trim();
if (q.length < 2) {
host.innerHTML = '';
return;
}
host.innerHTML = '<div class="p-2 small text-muted">Søger...</div>';
try {
const rows = await apiFetch(`/api/v1/emails/search-sager?q=${encodeURIComponent(q)}&limit=15`);
if (state.selectedEmailId !== emailId || document.getElementById('v2SagResults') !== host
|| document.getElementById('v2SagSearch')?.value.trim() !== q) return;
const list = Array.isArray(rows) ? rows : [];
if (!list.length) {
host.innerHTML = '<div class="p-2 small text-muted">Ingen sager fundet</div>';
return;
}
host.innerHTML = list.map((item) => `
<div class="emails-v2-sag-result" data-sag-id="${Number(item.id)}">
<div class="fw-semibold">#${Number(item.id)} ${escapeHtml(item.titel || 'Uden titel')}</div>
<div class="small text-muted">${escapeHtml(item.customer_name || 'Ukendt kunde')} • ${escapeHtml(item.status || '-')}</div>
</div>
`).join('');
host.querySelectorAll('[data-sag-id]').forEach((node) => {
node.addEventListener('click', () => {
linkSelectedSag(Number(node.getAttribute('data-sag-id')));
});
});
} catch (error) {
host.innerHTML = `<div class="p-2 small text-danger">Fejl: ${escapeHtml(error.message)}</div>`;
}
}
function renderDetail(email) {
const mailHeader = document.getElementById('v2MailHeader');
const quickActions = document.getElementById('v2QuickActions');
const mailBody = document.getElementById('v2MailBody');
const sideActions = document.getElementById('v2SideActions');
if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
if (!email) {
mailHeader.innerHTML = 'Vælg en email for at se info';
quickActions.innerHTML = '';
mailBody.className = 'emails-v2-detail-empty';
mailBody.textContent = 'Vælg en email fra listen';
sideActions.className = 'emails-v2-detail-empty';
sideActions.textContent = 'Vælg en email for handlinger';
setMailStatus('Ingen email valgt');
return;
}
const headerLink = email.linked_case_id
? `<a href="/sag/${Number(email.linked_case_id)}/v3">SAG #${Number(email.linked_case_id)}</a>`
: 'Ikke linket til sag';
mailHeader.innerHTML = `
<div class="emails-v2-subject">${escapeHtml(email.subject || '(Ingen emne)')}</div>
<div class="emails-v2-topmeta mb-0">
<span>Fra: ${escapeHtml(email.sender_name || '')} &lt;${escapeHtml(email.sender_email || '-')} &gt;</span>
<span>Modtaget: ${escapeHtml(formatDate(email.received_date))}</span>
<span>Status: ${escapeHtml(email.status || '-')}</span>
<span>${headerLink}</span>
</div>
`;
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
const classification = String(email.classification || '').toLowerCase();
const recommendedFlow = classification === 'invoice'
? 'create_supplier_invoice'
: (classification === 'customer_email' ? 'create_customer_case' : '');
const recommendationBadge = (flow) => recommendedFlow === flow
? '<span class="flow-badge">ANBEFALET</span>'
: '';
const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : '';
quickActions.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
` : `
<div class="emails-v2-flow-picker">
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow">
<button class="emails-v2-flow-choice flow-support${recommendationClass('create_customer_case')}" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
${recommendationBadge('create_customer_case')}
<span class="flow-title"><i class="bi bi-headset me-1"></i>Support</span>
<span class="flow-description">Opret en almindelig kundesag</span>
</button>
<button class="emails-v2-flow-choice flow-invoice${recommendationClass('create_supplier_invoice')}" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
${recommendationBadge('create_supplier_invoice')}
<span class="flow-title"><i class="bi bi-receipt me-1"></i>Leverandørfaktura</span>
<span class="flow-description">Aflæs fakturabilag og kør leverandørflowet</span>
</button>
<button class="emails-v2-flow-choice flow-accounting${recommendationClass('create_accounting_case')}" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
${recommendationBadge('create_accounting_case')}
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
<span class="flow-description">Opret kundesag hos Bogholderi</span>
</button>
<button class="emails-v2-flow-choice flow-archive${recommendationClass('archive_entity_mail')}" data-v2-quick-action="archive_entity_mail" title="Arkiverer kun mailen; opretter intet">
${recommendationBadge('archive_entity_mail')}
<span class="flow-title"><i class="bi bi-archive me-1"></i>Kun arkivér</span>
<span class="flow-description">Ingen sag eller anden behandling</span>
</button>
<button id="v2LinkCaseAction" class="emails-v2-flow-choice flow-case">
<span class="flow-title"><i class="bi bi-link-45deg me-1"></i>Knyt til sag</span>
<span class="flow-description">Find og vælg en eksisterende sag</span>
</button>
${hasSupplier ? `<button id="v2SupplierCaseAction" class="emails-v2-flow-choice flow-case">
<span class="flow-title"><i class="bi bi-truck me-1"></i>Leverandørsag</span>
<span class="flow-description">Opret sag til dialog med leverandøren</span>
</button>` : ''}
<button type="button" data-purchase-kind="order_confirmation" class="emails-v2-flow-choice flow-purchase">
<span class="flow-title"><i class="bi bi-bag-check me-1"></i>Ordrebekræftelse</span>
<span class="flow-description">Knyt mailen til en bestilling</span>
</button>
<button type="button" data-purchase-kind="freight_note" class="emails-v2-flow-choice flow-purchase">
<span class="flow-title"><i class="bi bi-box-seam me-1"></i>Levering / tracking</span>
<span class="flow-description">Vælg bestilling og tilføj tracking</span>
</button>
</div>
</div>
`;
mailBody.className = 'emails-v2-mail-body';
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
const rawHtml = String(email.body_html || '').trim();
const sanitizedHtml = sanitizeEmailHtml(rawHtml);
const renderedMessage = sanitizedHtml || formatEmailPlainText(email.body_text || 'Ingen indhold');
const messageClass = sanitizedHtml ? 'emails-v2-body html' : 'emails-v2-body';
mailBody.innerHTML = `
<div class="emails-v2-card">
<h6>Besked</h6>
<div class="${messageClass}">${renderedMessage}</div>
</div>
${hasAttachments ? `
<div class="emails-v2-card">
<h6>Vedhæftninger (${email.attachments.length})</h6>
<div class="emails-v2-attachments">
${email.attachments.map((att) => {
const name = att.filename || `Vedhæftning ${att.id}`;
return `<a class="btn btn-sm btn-outline-secondary" href="/api/v1/emails/${Number(email.id)}/attachments/${Number(att.id)}">${escapeHtml(name)}</a>`;
}).join('')}
</div>
</div>` : ''}
`;
sideActions.className = 'emails-v2-actions-pane';
sideActions.innerHTML = `
<div id="v2SenderIdentity" class="emails-v2-card"></div>
<div id="v2CreateSenderCard" class="emails-v2-card" hidden></div>
<form id="v2PurchaseMailForm" class="emails-v2-card" hidden>
<h6 id="v2PurchaseMailTitle">Ordre og levering</h6>
<label class="small fw-semibold" for="v2PurchaseSearch">Vælg bestilling</label>
<input id="v2PurchaseSearch" class="form-control form-control-sm mb-2" placeholder="Søg vare, reference, kunde eller sag" autocomplete="off">
<div class="small text-muted mb-2">Seneste bestillinger vises først. Søg for at finde ældre.</div>
<div id="v2PurchaseOptions" style="max-height:240px;overflow:auto"></div>
<div id="v2PurchaseSelected" class="small fw-semibold my-2"></div>
<div id="v2PurchaseTrackingField" hidden>
<label class="small" for="v2PurchaseTracking">Trackingnummer (valgfrit)</label>
<input id="v2PurchaseTracking" maxlength="100" class="form-control form-control-sm mb-2">
</div>
<div class="small text-muted mb-2">Mailen knyttes til bestillingen og sagen. Varen markeres ikke som modtaget.</div>
<button id="v2PurchaseSubmit" class="btn btn-primary btn-sm" type="submit" disabled>Knyt mail til bestilling</button>
<button id="v2PurchaseCancel" class="btn btn-outline-secondary btn-sm" type="button">Annuller</button>
<div id="v2PurchaseStatus" class="small mt-2" role="status"></div>
</form>
${hasSupplier && !email.linked_case_id ? `
<form id="v2SupplierCaseForm" class="emails-v2-card" hidden>
<h6>Ny leverandørsag</h6>
<div class="small text-muted mb-2">Dialog med ${escapeHtml(email.supplier_name || 'leverandøren')}. Mailen og dens bilag knyttes til sagen.</div>
<label for="v2SupplierCaseCustomer" class="small fw-semibold">Hvem er sagen for?</label>
<input id="v2SupplierCaseCustomer" class="form-control form-control-sm mb-1" autocomplete="off" value="${escapeHtml(email.customer_name || '')}" placeholder="Søg kunde — eller BMC til en intern sag">
<div id="v2SupplierCaseCustomers"></div>
<label for="v2SupplierCaseTitle" class="small fw-semibold mt-2">Titel</label>
<input id="v2SupplierCaseTitle" class="form-control form-control-sm mb-2" required value="${escapeHtml(email.subject || '')}">
<div class="d-flex gap-2">
<button id="v2SupplierCaseSubmit" type="submit" class="btn btn-primary btn-sm" ${hasCustomer ? '' : 'disabled'}>Opret leverandørsag</button>
<button id="v2SupplierCaseCancel" type="button" class="btn btn-outline-secondary btn-sm">Annuller</button>
</div>
<div id="v2SupplierCaseStatus" class="small mt-2" role="status"></div>
</form>` : ''}
<div class="emails-v2-card">
<h6>Sag</h6>
${email.linked_case_id ? `<a href="/sag/${Number(email.linked_case_id)}/v3">Åbn SAG #${Number(email.linked_case_id)}</a><div class="small text-muted mt-1">Mailen er knyttet til sagen.</div>` : '<div class="small text-muted">Ingen sag tilknyttet.</div>'}
<div id="v2CaseLinkPicker" class="mt-3">
<label for="v2SagSearch" class="small fw-semibold">${email.linked_case_id ? 'Skift sag' : 'Knyt til eksisterende sag'}</label>
<input id="v2SagSearch" class="form-control form-control-sm mt-2" placeholder="Sagsnummer eller titel…">
<div id="v2SagResults" class="emails-v2-sag-results"></div>
</div>
</div>
<details class="emails-v2-card"><summary class="small">Mailvurdering</summary>
<div id="v2DecisionCard" class="email-ai-decision">
<div class="d-flex align-items-center gap-2 text-muted">
<span class="spinner-border spinner-border-sm"></span>
Systemet vurderer mailtype og handlinger…
</div>
</div>
</details>
<details class="emails-v2-card">
<summary class="fw-semibold" style="cursor:pointer;">
<i class="bi bi-three-dots me-1"></i>Flere handlinger
</summary>
<div class="mt-3">
${email.linked_case_id ? `
<div class="emails-primary-action rounded p-3 mb-3">
<h6>Allerede knyttet til sag</h6>
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
<button id="v2Processed" class="btn btn-sm btn-outline-success w-100 mt-2">
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
</button>
</div>` : `
<details class="border rounded p-3 mb-3">
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary>
<div class="mt-3">
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm">
<option value="support" ${!email.supplier_id ? 'selected' : ''}>Support</option>
<option value="bogholderi">Bogholderi</option>
<option value="leverandor" ${email.supplier_id ? 'selected' : ''}>Leverandør</option>
<option value="helhedsopgave">Projekt/helhedsopgave</option>
<option value="andet">Andet</option>
</select>
<button id="v2CreateCase" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-lg me-1"></i>Opret
</button>
</div>
</div>
</details>`}
<div class="border rounded p-3 mb-3">
<h6>Hurtige mailhandlinger</h6>
<div class="emails-v2-actions mb-0">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-envelope${email.is_read ? '' : '-open'} me-1"></i>${email.is_read ? 'Ulæst' : 'Læst'}
</button>
<button id="v2Archive" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-archive me-1"></i>Arkivér
</button>
${email.linked_case_id ? '' : `
<button id="v2Processed" class="btn btn-sm btn-outline-success">
<i class="bi bi-check2 me-1"></i>Behandlet
</button>`}
</div>
</div>
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
<button id="v2AutoParse" class="btn btn-sm btn-outline-primary">Find tråd/sag automatisk</button>
</div>
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details>
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Beløb</div><div class="v">${escapeHtml(String(email.extracted_amount || '-'))}</div></div>
<div class="emails-v2-kv"><div class="k">Forfald</div><div class="v">${escapeHtml(email.extracted_due_date || '-')}</div></div>
<div class="emails-v2-right-actions mt-2">
<button id="v2ExtractVendor" class="btn btn-sm btn-outline-warning">Udtræk leverandørforslag</button>
</div>
<div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div>
<div id="v2SupplierStatus" class="emails-v2-inline-status"></div>
<hr>
<h6>Automatisk kundematch</h6>
<div class="emails-v2-right-actions">
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
</div>
<div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div>
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</details>
<details class="border rounded p-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
email_id: email.id,
message_id: email.message_id,
in_reply_to: email.in_reply_to,
email_references: email.email_references,
classification: email.classification,
confidence_score: email.confidence_score,
linked_case_id: email.linked_case_id,
}, null, 2))}</pre>
</details>
</div>
</details>
`;
// Identity and case handling remain visible; secondary actions keep their
// native <details> disclosure so the primary task stays focused.
document.querySelectorAll('[data-purchase-kind]').forEach(button => button.addEventListener('click', () => openPurchaseMail(button.dataset.purchaseKind)));
document.getElementById('v2PurchaseMailForm')?.addEventListener('submit', submitPurchaseMail);
document.getElementById('v2PurchaseCancel')?.addEventListener('click', () => {document.getElementById('v2PurchaseMailForm').hidden = true;});
document.getElementById('v2PurchaseSearch')?.addEventListener('input', () => {
state.selectedPurchase = null;
document.getElementById('v2PurchaseSelected').textContent = '';
document.getElementById('v2PurchaseSubmit').disabled = true;
clearTimeout(state.purchaseSearchTimer);
state.purchaseSearchTimer = setTimeout(searchPurchaseOptions, 220);
});
state.supplierCaseCustomer = hasCustomer ? {id: Number(email.customer_id), name: email.customer_name} : null;
document.getElementById('v2LinkCaseAction')?.addEventListener('click', openCaseLinkPicker);
document.getElementById('v2SupplierCaseAction')?.addEventListener('click', openSupplierCase);
document.getElementById('v2SupplierCaseForm')?.addEventListener('submit', createSupplierCase);
document.getElementById('v2SupplierCaseCancel')?.addEventListener('click', () => {
document.getElementById('v2SupplierCaseForm').hidden = true;
});
document.getElementById('v2SupplierCaseCustomer')?.addEventListener('input', () => {
state.supplierCaseCustomer = null;
document.getElementById('v2SupplierCaseSubmit').disabled = true;
document.getElementById('v2SupplierCaseCustomers').innerHTML = '';
clearTimeout(state.supplierCustomerTimer);
state.supplierCustomerTimer = setTimeout(searchSupplierCaseCustomer, 220);
});
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));
document.getElementById('v2Archive')?.addEventListener('click', archiveCurrent);
document.getElementById('v2Processed')?.addEventListener('click', markProcessedCurrent);
document.getElementById('v2Reprocess')?.addEventListener('click', reprocessCurrent);
document.getElementById('v2ExecuteWorkflows')?.addEventListener('click', executeWorkflowsCurrent);
document.getElementById('v2AutoRun')?.addEventListener('click', autoRunWorkflowsCurrent);
document.getElementById('v2AutoParse')?.addEventListener('click', autoParseAndLinkCurrent);
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent);
document.querySelectorAll('[data-v2-quick-action]').forEach((button) => {
button.addEventListener('click', () => {
runQuickAction(button.getAttribute('data-v2-quick-action'), button);
});
});
loadSenderIdentity();
state.vendorSuggestion = null;
state.domainCustomerSuggestion = null;
state.workflowPreview = null;
renderVendorSuggestion();
renderDomainCustomerSuggestion();
renderWorkflowPreview();
loadWorkflowPreviewCurrent();
setMailStatus(`Email #${email.id} vises`);
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
const query = event.target.value;
clearTimeout(state.sagSearchTimer);
state.sagSearchTimer = setTimeout(() => searchSager(query), 220);
});
}
async function uploadEmailFiles(files) {
const accepted = Array.from(files || []).filter((file) =>
/\.(eml|msg)$/i.test(file.name || '')
);
if (!accepted.length) {
setListStatus('Vælg .eml- eller .msg-filer');
return;
}
const zone = document.getElementById('v2UploadZone');
const label = document.getElementById('v2UploadLabel');
const original = label?.innerHTML;
if (zone) {
zone.classList.add('busy');
}
if (label) {
label.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>Importerer ${accepted.length} mail${accepted.length === 1 ? '' : 's'}…`;
}
try {
const formData = new FormData();
accepted.forEach((file) => formData.append('files', file));
const result = await apiFetch('/api/v1/emails/upload', {
method: 'POST',
body: formData,
});
const uploaded = Number(result?.uploaded || 0);
const duplicates = Number(result?.duplicates || 0);
setListStatus(`${uploaded} importeret${duplicates ? ` · ${duplicates} fandtes allerede` : ''}`);
state.filter = 'active';
state.selectedEmailId = null;
await loadEmails();
} catch (error) {
setListStatus(`Import fejlede: ${error.message}`);
} finally {
if (zone) {
zone.classList.remove('busy', 'dragover');
}
if (label) label.innerHTML = original;
}
}
function setupUploadZone() {
const zone = document.getElementById('v2UploadZone');
const input = document.getElementById('v2UploadInput');
if (!zone || !input || zone.dataset.ready === 'true') return;
zone.dataset.ready = 'true';
zone.addEventListener('click', () => input.click());
zone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
input.click();
}
});
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.add('dragover');
}));
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.remove('dragover');
}));
zone.addEventListener('drop', (event) => uploadEmailFiles(event.dataTransfer?.files));
input.addEventListener('change', () => {
uploadEmailFiles(input.files);
input.value = '';
});
}
function selectAdjacentEmail(offset) {
if (!state.emails.length) return;
const current = state.emails.findIndex((email) => Number(email.id) === Number(state.selectedEmailId));
const next = Math.min(Math.max((current < 0 ? 0 : current) + offset, 0), state.emails.length - 1);
selectEmail(Number(state.emails[next].id));
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (event) => {
const target = event.target;
const typing = target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable;
if (event.key === '/' && !typing) {
event.preventDefault();
document.getElementById('v2Search')?.focus();
} else if (!typing && event.key.toLowerCase() === 'j') {
selectAdjacentEmail(1);
} else if (!typing && event.key.toLowerCase() === 'k') {
selectAdjacentEmail(-1);
} else if (!typing && event.key.toLowerCase() === 'r') {
loadEmails();
}
});
}
function setupEvents() {
document.getElementById('v2Search')?.addEventListener('input', (event) => {
const value = String(event.target.value || '').trim();
clearTimeout(state.searchTimer);
state.searchTimer = setTimeout(() => {
state.query = value;
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
loadEmails();
}, 250);
});
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
setupUploadZone();
setupKeyboardShortcuts();
}
document.addEventListener('DOMContentLoaded', async () => {
setupEvents();
renderFilters();
renderDetail(null);
await loadEmails();
});
})();
</script>
{% endblock %}