2026-05-12 08:41:13 +02:00
{% extends "shared/frontend/base.html" %}
2026-07-28 14:18:24 +02:00
{% block title %}Email - BMC Hub{% endblock %}
2026-05-12 08:41:13 +02:00
{% 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);
}
2026-07-28 14:18:24 +02:00
.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));
}
2026-09-11 13:51:10 +02:00
.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;
2026-11-03 01:16:12 +01:00
min-height: 100px;
height: 100%;
2026-09-11 13:51:10 +02:00
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 {
2026-11-03 01:16:12 +01:00
border-color: var(--accent);
box-shadow: inset 0 0 0 2px var(--accent);
2026-09-11 13:51:10 +02:00
}
.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; }
2026-11-03 01:16:12 +01:00
.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; }
2026-09-11 13:51:10 +02:00
.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));
}
}
2026-07-30 20:11:43 +02:00
.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;
}
2026-07-28 14:18:24 +02:00
.emails-shortcuts {
font-size: 0.72rem;
color: var(--text-secondary);
}
.emails-shortcuts kbd {
font-size: 0.68rem;
}
2026-05-12 08:41:13 +02:00
.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));
}
2026-07-30 20:11:43 +02:00
.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;
}
2026-05-12 08:41:13 +02:00
.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 {
2026-10-08 08:58:02 +02:00
color: var(--theme-danger-text, #b42318);
2026-05-12 08:41:13 +02:00
}
.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;
}
}
2026-11-03 01:16:12 +01:00
@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;
}
}
2026-05-12 08:41:13 +02:00
< / 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" >
2026-07-28 14:18:24 +02:00
< 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 >
2026-05-12 08:41:13 +02:00
< / div >
< div class = "emails-v2-search" >
2026-07-28 14:18:24 +02:00
< input id = "v2Search" class = "form-control form-control-sm" placeholder = "Søg i mails… (tryk /)" >
2026-05-12 08:41:13 +02:00
< / div >
< div id = "v2Filters" class = "emails-v2-filters" > < / div >
2026-07-28 14:18:24 +02:00
< 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 >
2026-05-12 08:41:13 +02:00
< 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 >
2026-07-28 14:18:24 +02:00
< span class = "emails-shortcuts" > < kbd > J< / kbd > /< kbd > K< / kbd > næste/forrige< / span >
2026-05-12 08:41:13 +02:00
< / div >
< div id = "v2MailHeader" class = "emails-v2-mail-header small text-muted" > Vælg en email for at se info< / div >
2026-07-30 20:11:43 +02:00
< div id = "v2QuickActions" > < / div >
2026-05-12 08:41:13 +02:00
< 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 >
2026-07-28 14:18:24 +02:00
< span class = "small text-muted" > Vælg hvad der skal ske< / span >
2026-05-12 08:41:13 +02:00
< / 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' },
2026-11-03 01:16:12 +01:00
{ key: 'unread', label: 'Ulæste' },
2026-05-12 08:41:13 +02:00
{ key: 'awaiting_user_action', label: 'Afventer handling' },
{ key: 'processed', label: 'Behandlede' },
{ key: 'all', label: 'Alle' },
];
const state = {
emails: [],
selectedEmail: null,
selectedEmailId: null,
workflowPreview: null,
2026-11-03 01:16:12 +01:00
filter: new URLSearchParams(window.location.search).get('filter') === 'unread' ? 'unread' : 'active',
2026-05-12 08:41:13 +02:00
query: '',
2026-07-28 14:18:24 +02:00
folder: '',
2026-11-03 01:16:12 +01:00
offset: 0,
pageSize: 100,
totalCount: null,
hasMore: false,
loadingMore: false,
2026-05-12 08:41:13 +02:00
searchTimer: null,
sagSearchTimer: null,
vendorSuggestion: null,
domainCustomerSuggestion: null,
2026-11-03 01:16:12 +01:00
senderIdentity: null,
senderDraft: null,
senderDraftRequest: 0,
senderSearchTimer: null,
supplierCaseCustomer: null,
supplierCustomerTimer: null,
purchaseMailKind: null,
selectedPurchase: null,
purchaseSearchTimer: null,
queueCounts: null,
2026-05-12 08:41:13 +02:00
};
function escapeHtml(value) {
return String(value ?? '')
.replace(/& /g, '& ')
.replace(/< /g, '< ')
.replace(/>/g, '> ')
.replace(/"/g, '" ')
.replace(/'/g, '' ');
}
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;
2026-11-03 01:16:12 +01:00
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,
2026-05-12 08:41:13 +02:00
};
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';
2026-11-03 01:16:12 +01:00
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
2026-05-12 08:41:13 +02:00
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;
}
2026-11-03 01:16:12 +01:00
const rows = state.emails.map((email) => {
2026-05-12 08:41:13 +02:00
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('');
2026-11-03 01:16:12 +01:00
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;
2026-05-12 08:41:13 +02:00
host.querySelectorAll('[data-email-id]').forEach((node) => {
node.addEventListener('click', () => {
selectEmail(Number(node.getAttribute('data-email-id')));
});
});
2026-11-03 01:16:12 +01:00
document.getElementById('v2LoadMore')?.addEventListener('click', () => loadEmails({append: true}));
2026-05-12 08:41:13 +02:00
}
2026-11-03 01:16:12 +01:00
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') {
2026-05-12 08:41:13 +02:00
url += '&status=awaiting_user_action';
} else if (state.filter === 'processed') {
url += '&status=processed';
}
2026-11-03 01:16:12 +01:00
if (state.query) url += `&q=${encodeURIComponent(state.query)}`;
if (state.folder) url += `&folder=${encodeURIComponent(state.folder)}`;
return url;
}
2026-05-12 08:41:13 +02:00
2026-11-03 01:16:12 +01:00
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 > ';
2026-05-12 08:41:13 +02:00
}
try {
2026-11-03 01:16:12 +01:00
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),
};
2026-05-12 08:41:13 +02:00
2026-11-03 01:16:12 +01:00
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 ;
2026-05-12 08:41:13 +02:00
renderFilters();
renderList();
2026-11-03 01:16:12 +01:00
const totalLabel = state.totalCount === null ? `${state.emails.length}` : `${state.emails.length} af ${state.totalCount}`;
setListStatus(`Viser ${totalLabel} emails`);
2026-05-12 08:41:13 +02:00
} catch (error) {
2026-11-03 01:16:12 +01:00
if (!append) state.emails = [];
2026-05-12 08:41:13 +02:00
renderFilters();
renderList();
setListStatus(`Fejl: ${error.message}`);
2026-11-03 01:16:12 +01:00
} finally {
const wasLoadingMore = state.loadingMore;
state.loadingMore = false;
if (wasLoadingMore) renderList();
2026-05-12 08:41:13 +02:00
}
}
2026-11-03 01:16:12 +01:00
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) {
2026-05-12 08:41:13 +02:00
if (!emailId) return;
state.selectedEmailId = Number(emailId);
renderList();
setDetailStatus('Indlæser email...');
try {
const email = await apiFetch(`/api/v1/emails/${emailId}`);
2026-11-03 01:16:12 +01:00
if (state.selectedEmailId !== Number(emailId)) return;
2026-05-12 08:41:13 +02:00
state.selectedEmail = email;
2026-11-03 01:16:12 +01:00
syncSelectedEmailInList(email);
2026-05-12 08:41:13 +02:00
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;
2026-11-03 01:16:12 +01:00
const emailId = state.selectedEmailId;
2026-05-12 08:41:13 +02:00
try {
2026-11-03 01:16:12 +01:00
const preview = await apiFetch(`/api/v1/emails/${emailId}/workflow-preview`);
if (state.selectedEmailId !== emailId) return;
2026-05-12 08:41:13 +02:00
state.workflowPreview = preview || null;
2026-07-30 20:11:43 +02:00
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();
}
2026-05-12 08:41:13 +02:00
renderWorkflowPreview();
} catch (error) {
2026-11-03 01:16:12 +01:00
if (state.selectedEmailId !== emailId) return;
2026-05-12 08:41:13 +02:00
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}`);
}
}
2026-07-30 20:11:43 +02:00
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',
};
2026-11-03 01:16:12 +01:00
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));
2026-07-30 20:11:43 +02:00
}
2026-11-03 01:16:12 +01:00
}
2026-07-30 20:11:43 +02:00
2026-11-03 01:16:12 +01:00
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}`;
2026-07-30 20:11:43 +02:00
}
2026-11-03 01:16:12 +01:00
}
2026-07-30 20:11:43 +02:00
2026-11-03 01:16:12 +01:00
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 >
2026-07-30 20:11:43 +02:00
< / div >
2026-11-03 01:16:12 +01:00
< 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();
2026-07-30 20:11:43 +02:00
}
function bindIdentityDecisionActions() {
2026-11-03 01:16:12 +01:00
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}`;
}
2026-07-30 20:11:43 +02:00
}
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';
2026-09-11 13:51:10 +02:00
const email = state.selectedEmail || {};
const isApprovedSupplierInvoice = classification === 'invoice' & & Boolean(email.supplier_id);
2026-07-30 20:11:43 +02:00
const effects = [];
2026-09-11 13:51:10 +02:00
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}”`
));
});
}
2026-07-30 20:11:43 +02:00
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
2026-11-03 01:16:12 +01:00
< div class = "small text-uppercase fw-bold text-muted mb-1" > Mailtype< / div >
2026-09-11 13:51:10 +02:00
< 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 >
2026-07-30 20:11:43 +02:00
< / div >
2026-11-03 01:16:12 +01:00
< 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 >
2026-09-11 13:51:10 +02:00
< div class = "mt-3" >
2026-11-03 01:16:12 +01:00
2026-09-11 13:51:10 +02:00
< 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 ? `
2026-07-30 20:11:43 +02:00
< 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.
2026-09-11 13:51:10 +02:00
< / div > ` : ''}
` : '< div class = "small text-muted" > Ingen automatiske handlinger foreslås.< / div > '}
2026-07-30 20:11:43 +02:00
< / div >
2026-11-03 01:16:12 +01:00
< / details >
2026-07-30 20:11:43 +02:00
`;
2026-11-03 01:16:12 +01:00
simplifySideSections(host);
2026-07-30 20:11:43 +02:00
}
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 {
2026-09-11 13:51:10 +02:00
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
if (classification === 'invoice' & & state.selectedEmail?.supplier_id) {
await runQuickAction('create_supplier_invoice', button);
} else {
await executeWorkflowsCurrent();
}
2026-07-30 20:11:43 +02:00
} finally {
if (document.body.contains(button)) {
button.disabled = false;
button.innerHTML = '< i class = "bi bi-check-circle me-2" > < / i > Godkend og udfør';
}
}
}
2026-05-12 08:41:13 +02:00
function renderWorkflowPreview(errorMessage) {
const host = document.getElementById('v2WorkflowPreview');
if (!host) return;
if (errorMessage) {
host.innerHTML = `< div class = "small text-danger" > ${escapeHtml(errorMessage)}< / div > `;
2026-07-30 20:11:43 +02:00
renderClassificationDecision(errorMessage);
2026-05-12 08:41:13 +02:00
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '< div class = "small text-muted" > Ingen preview endnu< / div > ';
2026-07-30 20:11:43 +02:00
renderClassificationDecision();
2026-05-12 08:41:13 +02:00
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 || {};
2026-09-11 13:51:10 +02:00
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
& & Boolean(state.selectedEmail?.supplier_id);
2026-05-12 08:41:13 +02:00
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('');
2026-09-11 13:51:10 +02:00
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
2026-05-12 08:41:13 +02:00
? 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 >
`;
2026-07-30 20:11:43 +02:00
renderClassificationDecision();
2026-05-12 08:41:13 +02:00
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}`);
}
}
2026-11-03 01:16:12 +01:00
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;
}
}
2026-05-12 08:41:13 +02:00
async function createCaseFromCurrent() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const caseTypeEl = document.getElementById('v2CaseType');
const titelEl = document.getElementById('v2CaseTitle');
2026-07-30 20:11:43 +02:00
const createButton = document.getElementById('v2CreateCase');
2026-05-12 08:41:13 +02:00
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 {
2026-07-30 20:11:43 +02:00
if (createButton) {
createButton.disabled = true;
createButton.innerHTML = '< span class = "spinner-border spinner-border-sm me-1" > < / span > Opretter…';
}
2026-05-12 08:41:13 +02:00
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}`);
2026-07-30 20:11:43 +02:00
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;
}
2026-05-12 08:41:13 +02:00
}
}
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();
2026-07-30 20:11:43 +02:00
renderClassificationDecision();
2026-05-12 08:41:13 +02:00
setSupplierStatus('Forslag opdateret', 'success');
} catch (error) {
2026-07-30 20:11:43 +02:00
state.vendorSuggestion = {};
renderClassificationDecision();
2026-05-12 08:41:13 +02:00
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();
2026-07-30 20:11:43 +02:00
renderClassificationDecision();
2026-05-12 08:41:13 +02:00
setDomainStatus('Forslag opdateret', 'success');
} catch (error) {
2026-07-30 20:11:43 +02:00
state.domainCustomerSuggestion = {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
renderClassificationDecision();
2026-05-12 08:41:13 +02:00
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');
}
}
2026-07-30 20:11:43 +02:00
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}`);
}
}
2026-05-12 08:41:13 +02:00
async function searchSager(query) {
2026-11-03 01:16:12 +01:00
const emailId = state.selectedEmailId;
2026-05-12 08:41:13 +02:00
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`);
2026-11-03 01:16:12 +01:00
if (state.selectedEmailId !== emailId || document.getElementById('v2SagResults') !== host
|| document.getElementById('v2SagSearch')?.value.trim() !== q) return;
2026-05-12 08:41:13 +02:00
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');
2026-07-30 20:11:43 +02:00
const quickActions = document.getElementById('v2QuickActions');
2026-05-12 08:41:13 +02:00
const mailBody = document.getElementById('v2MailBody');
const sideActions = document.getElementById('v2SideActions');
2026-07-30 20:11:43 +02:00
if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
2026-05-12 08:41:13 +02:00
if (!email) {
mailHeader.innerHTML = 'Vælg en email for at se info';
2026-07-30 20:11:43 +02:00
quickActions.innerHTML = '';
2026-05-12 08:41:13 +02:00
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" >
2026-11-03 01:16:12 +01:00
< span > Fra: ${escapeHtml(email.sender_name || '')} < ${escapeHtml(email.sender_email || '-')} > < / span >
2026-05-12 08:41:13 +02:00
< span > Modtaget: ${escapeHtml(formatDate(email.received_date))}< / span >
< span > Status: ${escapeHtml(email.status || '-')}< / span >
< span > ${headerLink}< / span >
< / div >
`;
2026-07-30 20:11:43 +02:00
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
2026-09-11 13:51:10 +02:00
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' : '';
2026-07-30 20:11:43 +02:00
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 >
` : `
2026-09-11 13:51:10 +02:00
< 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 >
2026-07-30 20:11:43 +02:00
< / button >
2026-09-11 13:51:10 +02:00
< 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 >
2026-11-03 01:16:12 +01:00
< span class = "flow-description" > Aflæs fakturabilag og kør leverandørflowet< / span >
2026-07-30 20:11:43 +02:00
< / button >
2026-09-11 13:51:10 +02:00
< 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 >
2026-07-30 20:11:43 +02:00
< / button >
2026-11-03 01:16:12 +01:00
< 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" >
2026-09-11 13:51:10 +02:00
${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 >
2026-07-30 20:11:43 +02:00
< / button >
2026-11-03 01:16:12 +01:00
< 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 >
2026-09-11 13:51:10 +02:00
< / div >
2026-07-30 20:11:43 +02:00
< / div >
`;
2026-05-12 08:41:13 +02:00
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 = `
2026-11-03 01:16:12 +01:00
< 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 >
2026-07-30 20:11:43 +02:00
< 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 >
2026-11-03 01:16:12 +01:00
< / details >
2026-09-11 13:51:10 +02:00
< 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" >
2026-07-28 14:18:24 +02:00
${email.linked_case_id ? `
2026-09-11 13:51:10 +02:00
< div class = "emails-primary-action rounded p-3 mb-3" >
2026-07-28 14:18:24 +02:00
< 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 > ` : `
2026-09-11 13:51:10 +02:00
< 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" >
2026-07-28 14:18:24 +02:00
< 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" >
2026-07-30 20:11:43 +02:00
< option value = "support" $ { ! email . supplier_id ? ' selected ' : ' ' } > Support< / option >
2026-07-28 14:18:24 +02:00
< option value = "bogholderi" > Bogholderi< / option >
2026-07-30 20:11:43 +02:00
< option value = "leverandor" $ { email . supplier_id ? ' selected ' : ' ' } > Leverandør< / option >
2026-07-28 14:18:24 +02:00
< 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 >
2026-05-12 08:41:13 +02:00
< / div >
2026-09-11 13:51:10 +02:00
< / div >
< / details > `}
2026-05-12 08:41:13 +02:00
2026-09-11 13:51:10 +02:00
< div class = "border rounded p-3 mb-3" >
2026-07-28 14:18:24 +02:00
< 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 > `}
2026-05-12 08:41:13 +02:00
< / div >
< / div >
2026-09-11 13:51:10 +02:00
< details class = "border rounded p-3 mb-3" >
2026-07-28 14:18:24 +02:00
< 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 >
2026-09-11 13:51:10 +02:00
< details class = "border rounded p-3 mb-3" >
2026-07-28 14:18:24 +02:00
< summary class = "small fw-semibold" style = "cursor:pointer;" > Leverandør og kundematch< / summary >
< h6 class = "mt-3" > Leverandør faktura< / h6 >
2026-05-12 08:41:13 +02:00
< 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 >
2026-07-28 14:18:24 +02:00
< hr >
< h6 > Automatisk kundematch< / h6 >
2026-05-12 08:41:13 +02:00
< 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 >
2026-07-28 14:18:24 +02:00
< / details >
2026-05-12 08:41:13 +02:00
2026-09-11 13:51:10 +02:00
< details class = "border rounded p-3" >
2026-05-12 08:41:13 +02:00
< 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 >
2026-09-11 13:51:10 +02:00
< / div >
< / details >
2026-05-12 08:41:13 +02:00
`;
2026-11-03 01:16:12 +01:00
// 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);
});
2026-05-12 08:41:13 +02:00
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);
2026-07-30 20:11:43 +02:00
document.querySelectorAll('[data-v2-quick-action]').forEach((button) => {
button.addEventListener('click', () => {
runQuickAction(button.getAttribute('data-v2-quick-action'), button);
});
});
2026-05-12 08:41:13 +02:00
2026-11-03 01:16:12 +01:00
loadSenderIdentity();
2026-05-12 08:41:13 +02:00
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);
});
}
2026-07-28 14:18:24 +02:00
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();
}
});
}
2026-05-12 08:41:13 +02:00
function setupEvents() {
document.getElementById('v2Search')?.addEventListener('input', (event) => {
const value = String(event.target.value || '').trim();
clearTimeout(state.searchTimer);
state.searchTimer = setTimeout(() => {
state.query = value;
2026-11-03 01:16:12 +01:00
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
2026-05-12 08:41:13 +02:00
loadEmails();
}, 250);
});
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
2026-07-28 14:18:24 +02:00
setupUploadZone();
setupKeyboardShortcuts();
2026-05-12 08:41:13 +02:00
}
document.addEventListener('DOMContentLoaded', async () => {
setupEvents();
renderFilters();
renderDetail(null);
await loadEmails();
});
})();
< / script >
{% endblock %}