bmc_hub/app/emails/frontend/emails_v2.html

2473 lines
110 KiB
HTML
Raw Normal View History

{% 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 %}