bmc_hub/app/emails/frontend/emails_v2.html

2045 lines
82 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

{% extends "shared/frontend/base.html" %}
{% block title %}Email - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.emails-v2-shell {
display: grid;
grid-template-columns: 340px minmax(0, 1fr) 420px;
gap: 1rem;
height: calc(100vh - 140px);
min-height: 620px;
}
.emails-v2-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
min-width: 0;
}
.emails-v2-header {
padding: 0.9rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.emails-v2-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.emails-v2-version-nav {
display: flex;
gap: 0.5rem;
}
.emails-v2-search {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--border-color);
}
.emails-quick-upload {
margin: 0.65rem 0.9rem;
padding: 0.55rem 0.7rem;
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 45%, var(--border-color));
border-radius: 9px;
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
text-align: center;
transition: 0.15s ease;
}
.emails-quick-upload:hover,
.emails-quick-upload.dragover {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent, #0f4c75) 9%, var(--bg-card));
}
.emails-quick-upload.busy {
pointer-events: none;
opacity: 0.65;
}
.emails-primary-action {
border: 1px solid color-mix(in srgb, var(--accent, #0f4c75) 35%, var(--border-color));
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-flow-picker {
width: 100%;
}
.emails-v2-flow-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 0.65rem;
}
.emails-v2-flow-choice {
position: relative;
min-height: 74px;
padding: 0.72rem 0.8rem;
border: 1px solid var(--border-color);
border-radius: 10px;
background: #52606d;
color: #fff;
text-align: left;
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16);
}
.emails-v2-flow-choice:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
}
.emails-v2-flow-choice.recommended {
border: 3px solid #fff;
outline: 3px solid var(--accent);
}
.emails-v2-flow-choice.flow-support { background: #1769aa; }
.emails-v2-flow-choice.flow-invoice { background: #18864b; }
.emails-v2-flow-choice.flow-accounting { background: #d97706; }
.emails-v2-flow-choice.flow-archive { background: #59636e; }
.emails-v2-flow-choice:disabled {
opacity: 0.42;
box-shadow: none;
}
.emails-v2-flow-choice .flow-title {
display: block;
font-weight: 700;
}
.emails-v2-flow-choice .flow-description {
display: block;
margin-top: 0.22rem;
color: rgba(255, 255, 255, 0.88);
font-size: 0.76rem;
line-height: 1.25;
}
.emails-v2-flow-choice .flow-badge {
position: absolute;
top: -0.55rem;
right: 0.45rem;
padding: 0.12rem 0.42rem;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 0.66rem;
font-weight: 700;
}
@media (max-width: 1500px) {
.emails-v2-flow-grid {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
.email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px;
padding: 0.9rem;
margin-bottom: 0.85rem;
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.email-ai-type {
font-size: 1.15rem;
font-weight: 750;
}
.email-ai-confidence {
height: 7px;
border-radius: 99px;
overflow: hidden;
background: var(--border-color);
}
.email-ai-confidence > span {
display: block;
height: 100%;
background: var(--accent);
}
.email-ai-actions {
margin: 0;
padding-left: 1.2rem;
font-size: 0.86rem;
}
.email-ai-actions li {
margin-bottom: 0.32rem;
}
.emails-shortcuts {
font-size: 0.72rem;
color: var(--text-secondary);
}
.emails-shortcuts kbd {
font-size: 0.68rem;
}
.emails-v2-filters {
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
}
.emails-v2-filter {
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
padding: 0.24rem 0.62rem;
}
.emails-v2-filter.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.emails-v2-list {
overflow: auto;
flex: 1;
}
.emails-v2-item {
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 0.9rem;
cursor: pointer;
}
.emails-v2-item:hover {
background: color-mix(in srgb, var(--accent, #0f4c75) 6%, var(--bg-card));
}
.emails-v2-item.active {
background: color-mix(in srgb, var(--accent, #0f4c75) 14%, var(--bg-card));
border-left: 3px solid var(--accent);
padding-left: calc(0.9rem - 3px);
}
.emails-v2-item.unread .subject {
font-weight: 700;
}
.emails-v2-item .head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
.emails-v2-item .sender {
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-item .time {
font-size: 0.74rem;
color: var(--text-secondary);
white-space: nowrap;
}
.emails-v2-item .subject {
margin-top: 0.2rem;
font-size: 0.88rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-item .meta {
margin-top: 0.28rem;
font-size: 0.75rem;
color: var(--text-secondary);
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.emails-v2-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--text-secondary);
}
.emails-v2-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.emails-v2-detail {
flex: 1;
overflow: auto;
padding: 1rem;
}
.emails-v2-mail-header {
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--border-color);
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-quick-toolbar {
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
}
.emails-v2-quick-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
.emails-v2-quick-grid .btn {
min-height: 38px;
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emails-v2-mail-body {
flex: 1;
overflow: auto;
padding: 1rem;
}
.emails-v2-actions-pane {
flex: 1;
overflow: auto;
padding: 1rem;
}
.emails-v2-subject {
font-size: 1.18rem;
font-weight: 700;
margin-bottom: 0.35rem;
word-break: break-word;
}
.emails-v2-topmeta {
color: var(--text-secondary);
font-size: 0.84rem;
margin-bottom: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.emails-v2-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-bottom: 0.95rem;
justify-content: flex-start;
}
.emails-v2-right-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
}
.emails-v2-nextstep {
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 38%, transparent);
border-radius: 10px;
padding: 0.7rem;
margin-bottom: 0.9rem;
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
}
.emails-v2-nextstep .label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 0.3rem;
}
.emails-v2-card {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.emails-v2-card h6 {
margin: 0 0 0.5rem 0;
font-size: 0.82rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.emails-v2-kv {
display: grid;
grid-template-columns: 130px 1fr;
gap: 0.35rem;
font-size: 0.82rem;
margin-bottom: 0.25rem;
}
.emails-v2-kv .k {
color: var(--text-secondary);
font-weight: 600;
}
.emails-v2-kv .v {
color: var(--text-primary);
overflow-wrap: anywhere;
}
.emails-v2-inline-status {
margin-top: 0.55rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.emails-v2-inline-status.error {
color: #b42318;
}
.emails-v2-inline-status.success {
color: #067647;
}
.emails-v2-body {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.35;
font-size: 0.92rem;
}
.emails-v2-body.html {
white-space: normal;
line-height: 1.5;
}
.emails-v2-body.html p,
.emails-v2-body.html ul,
.emails-v2-body.html ol,
.emails-v2-body.html blockquote,
.emails-v2-body.html pre,
.emails-v2-body.html table {
margin-bottom: 0.75rem;
}
.emails-v2-body.html table {
width: 100%;
border-collapse: collapse;
}
.emails-v2-body.html th,
.emails-v2-body.html td {
border: 1px solid var(--border-color);
padding: 0.35rem 0.45rem;
vertical-align: top;
}
.emails-v2-body.html a {
word-break: break-all;
}
.emails-v2-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.emails-v2-sag-results {
max-height: 220px;
overflow: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.emails-v2-sag-result {
padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.emails-v2-sag-result:last-child {
border-bottom: none;
}
.emails-v2-sag-result:hover {
background: color-mix(in srgb, var(--accent, #0f4c75) 8%, var(--bg-card));
}
.emails-v2-status {
padding: 0.5rem 0.9rem;
border-top: 1px solid var(--border-color);
font-size: 0.8rem;
color: var(--text-secondary);
}
@media (max-width: 1100px) {
.emails-v2-shell {
grid-template-columns: 1fr;
height: auto;
min-height: 0;
}
.emails-v2-panel {
min-height: 420px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="emails-v2-shell">
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h1 class="emails-v2-title"><i class="bi bi-inbox me-2"></i>Email</h1>
<button id="v2Refresh" class="btn btn-sm btn-outline-primary" title="Opdater (R)">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="emails-v2-search">
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg i mails… (tryk /)">
</div>
<div id="v2Filters" class="emails-v2-filters"></div>
<div class="emails-quick-upload" id="v2UploadZone" role="button" tabindex="0">
<span id="v2UploadLabel">
<i class="bi bi-paperclip me-1"></i>Træk .eml/.msg hertil eller klik
</span>
<input id="v2UploadInput" type="file" accept=".eml,.msg" multiple hidden>
</div>
<div id="v2List" class="emails-v2-list"></div>
<div id="v2ListStatus" class="emails-v2-status">Klar</div>
</section>
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h2 class="emails-v2-title">Detalje</h2>
<span class="emails-shortcuts"><kbd>J</kbd>/<kbd>K</kbd> næste/forrige</span>
</div>
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
<div id="v2QuickActions"></div>
<div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div>
<div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
</section>
<section class="emails-v2-panel">
<div class="emails-v2-header">
<h2 class="emails-v2-title">Handlinger</h2>
<span class="small text-muted">Vælg hvad der skal ske</span>
</div>
<div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
<div id="v2DetailStatus" class="emails-v2-status">Ingen email valgt</div>
</section>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(() => {
const FILTERS = [
{ key: 'active', label: 'Aktive' },
{ key: 'awaiting_user_action', label: 'Afventer handling' },
{ key: 'processed', label: 'Behandlede' },
{ key: 'all', label: 'Alle' },
];
const state = {
emails: [],
selectedEmail: null,
selectedEmailId: null,
workflowPreview: null,
filter: 'active',
query: '',
folder: '',
searchTimer: null,
sagSearchTimer: null,
vendorSuggestion: null,
domainCustomerSuggestion: null,
};
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatEmailPlainText(value) {
return escapeHtml(value || '').replace(/\n/g, '<br>');
}
function isSafeUrl(url) {
if (!url) return false;
const normalized = String(url).trim().toLowerCase();
return normalized.startsWith('http://')
|| normalized.startsWith('https://')
|| normalized.startsWith('mailto:')
|| normalized.startsWith('tel:')
|| normalized.startsWith('/');
}
function sanitizeEmailHtml(unsafeHtml) {
const input = String(unsafeHtml || '').trim();
if (!input) return '';
const allowedTags = new Set([
'a', 'b', 'strong', 'i', 'em', 'u', 's', 'br', 'p', 'div', 'span',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
]);
const allowedAttrs = {
a: new Set(['href', 'title', 'target', 'rel']),
th: new Set(['colspan', 'rowspan']),
td: new Set(['colspan', 'rowspan']),
};
const parser = new DOMParser();
const doc = parser.parseFromString(input, 'text/html');
const cleanNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return document.createTextNode('');
}
const tag = node.tagName.toLowerCase();
if (!allowedTags.has(tag)) {
const fragment = document.createDocumentFragment();
Array.from(node.childNodes).forEach((child) => {
fragment.appendChild(cleanNode(child));
});
return fragment;
}
const el = document.createElement(tag);
const tagAllowedAttrs = allowedAttrs[tag] || new Set();
Array.from(node.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (!tagAllowedAttrs.has(name)) return;
if (tag === 'a' && name === 'href') {
if (!isSafeUrl(value)) return;
el.setAttribute('href', value);
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
return;
}
if ((name === 'colspan' || name === 'rowspan')) {
const num = Number(value);
if (!Number.isInteger(num) || num < 1 || num > 100) return;
el.setAttribute(name, String(num));
return;
}
el.setAttribute(name, value);
});
Array.from(node.childNodes).forEach((child) => {
el.appendChild(cleanNode(child));
});
return el;
};
const wrapper = document.createElement('div');
Array.from(doc.body.childNodes).forEach((child) => {
wrapper.appendChild(cleanNode(child));
});
return wrapper.innerHTML;
}
function formatDate(value) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString('da-DK');
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, { credentials: 'include', ...options });
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const payload = await response.json();
detail = payload?.detail || detail;
} catch (_) {
const text = await response.text().catch(() => '');
if (text) detail = text;
}
throw new Error(detail);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
return response.text();
}
function setListStatus(message) {
const el = document.getElementById('v2ListStatus');
if (el) el.textContent = message || '';
}
function setDetailStatus(message) {
const el = document.getElementById('v2DetailStatus');
if (el) el.textContent = message || '';
}
function setMailStatus(message) {
const el = document.getElementById('v2MailStatus');
if (el) el.textContent = message || '';
}
function renderFilters() {
const host = document.getElementById('v2Filters');
if (!host) return;
const counts = {
active: state.emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase())).length,
awaiting_user_action: state.emails.filter((e) => (e.status || '').toLowerCase() === 'awaiting_user_action').length,
processed: state.emails.filter((e) => (e.status || '').toLowerCase() === 'processed').length,
all: state.emails.length,
};
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';
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;
}
host.innerHTML = state.emails.map((email) => {
const active = Number(email.id) === Number(state.selectedEmailId) ? 'active' : '';
const unread = email.is_read ? '' : 'unread';
const sender = email.sender_name || email.sender_email || 'Ukendt';
const preview = (email.body_text || email.subject || '').slice(0, 90).replace(/\s+/g, ' ').trim();
return `
<article class="emails-v2-item ${active} ${unread}" data-email-id="${Number(email.id)}">
<div class="head">
<div class="sender">${escapeHtml(sender)}</div>
<div class="time">${escapeHtml(formatDate(email.received_date))}</div>
</div>
<div class="subject">${escapeHtml(email.subject || '(Ingen emne)')}</div>
<div class="meta">
<span>Status: ${escapeHtml(email.status || '-')}</span>
<span>Type: ${escapeHtml(email.classification || 'general')}</span>
${email.linked_case_id ? `<span>SAG #${Number(email.linked_case_id)}</span>` : ''}
</div>
${preview ? `<div class="meta">${escapeHtml(preview)}</div>` : ''}
</article>
`;
}).join('');
host.querySelectorAll('[data-email-id]').forEach((node) => {
node.addEventListener('click', () => {
selectEmail(Number(node.getAttribute('data-email-id')));
});
});
}
async function loadEmails() {
setListStatus('Indlæser emails...');
const host = document.getElementById('v2List');
if (host) {
host.innerHTML = '<div class="emails-v2-empty"><div class="spinner-border spinner-border-sm"></div> Henter...</div>';
}
let url = '/api/v1/emails?limit=150';
if (state.filter === 'awaiting_user_action') {
url += '&status=awaiting_user_action';
} else if (state.filter === 'processed') {
url += '&status=processed';
}
if (state.query) {
url += `&q=${encodeURIComponent(state.query)}`;
}
if (state.folder) {
url += `&folder=${encodeURIComponent(state.folder)}`;
}
try {
const rows = await apiFetch(url);
let emails = Array.isArray(rows) ? rows : [];
// Processed/archived emails must never reappear in the active
// inbox, including when the user searches.
if (state.filter === 'active') {
emails = emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase()));
}
state.emails = emails;
renderFilters();
renderList();
setListStatus(`${emails.length} emails`);
if (state.selectedEmailId) {
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
if (stillExists) {
await selectEmail(state.selectedEmailId, { silentListRefresh: true });
} else {
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
if (emails.length) {
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
}
}
} else if (emails.length) {
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
}
} catch (error) {
state.emails = [];
renderFilters();
renderList();
setListStatus(`Fejl: ${error.message}`);
}
}
async function selectEmail(emailId, options = {}) {
if (!emailId) return;
state.selectedEmailId = Number(emailId);
renderList();
setDetailStatus('Indlæser email...');
try {
const email = await apiFetch(`/api/v1/emails/${emailId}`);
state.selectedEmail = email;
renderDetail(email);
setDetailStatus(`Email #${emailId} indlæst`);
if (!options.silentListRefresh) {
await loadEmails();
}
} 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;
try {
const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`);
state.workflowPreview = preview || null;
if (preview?.identity) {
state.domainCustomerSuggestion = preview.identity.customer || {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
state.vendorSuggestion = preview.identity.vendor?.suggestion || {};
renderDomainCustomerSuggestion();
renderVendorSuggestion();
}
renderWorkflowPreview();
} catch (error) {
state.workflowPreview = null;
renderWorkflowPreview(`Kunne ikke hente workflow-match: ${error.message}`);
}
}
async function autoRunWorkflowsCurrent() {
if (!state.selectedEmailId) return;
try {
setDetailStatus('Kører autokør...');
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/auto-run-workflows`, {
method: 'POST',
});
await selectEmail(state.selectedEmailId);
const executed = Number(result?.workflows_executed || result?.executed || 0);
setDetailStatus(`Autokør fuldført (${executed})`);
} catch (error) {
setDetailStatus(`Autokør fejlede: ${error.message}`);
}
}
const CLASSIFICATION_LABELS = {
invoice: 'Leverandørfaktura',
freight_note: 'Fragtbrev',
order_confirmation: 'Ordrebekræftelse',
time_confirmation: 'Tidsbekræftelse',
case_notification: 'Sagsnotifikation',
customer_email: 'Kundehenvendelse',
bankruptcy: 'Konkursmeddelelse',
general: 'Almindelig email',
spam: 'Spam',
newsletter: 'Nyhedsbrev',
unknown: 'Ukendt type',
};
const WORKFLOW_ACTION_LABELS = {
create_ticket: 'Opretter en sag/opgave',
link_email_to_ticket: 'Knytter emailen til en eksisterende sag',
route_helpdesk_sag: 'Finder eller opretter den relevante sag',
create_time_entry: 'Opretter en tidsregistrering',
link_to_vendor: 'Knytter emailen til leverandøren',
link_to_customer: 'Knytter emailen til kunden',
extract_invoice_data: 'Sender PDF-bilaget til Leverandørfakturaer → Mangler behandling',
extract_tracking_number: 'Udtrækker trackingnummer',
regex_extract_and_link: 'Finder reference og opretter tilknytning',
process_also_cloud_billing: 'Importerer ALSO-fakturafilen og opretter ordrekladder',
send_slack_notification: 'Sender en Slack-notifikation',
send_email_notification: 'Sender en emailnotifikation',
mark_as_processed: 'Markerer emailen som behandlet',
flag_for_review: 'Markerer emailen til manuel kontrol',
};
function renderIdentityAssessment() {
const email = state.selectedEmail || {};
const customerData = state.domainCustomerSuggestion;
const customerSuggestion = customerData?.suggestion;
const vendorSuggestion = state.vendorSuggestion;
const senderDomain = customerData?.domain
|| String(email.sender_email || '').split('@')[1]
|| '';
let customerHtml;
if (customerData?.not_applicable) {
customerHtml = `
<div class="fw-semibold">Ikke relevant</div>
<div class="small text-muted">Dette er et leverandørdokument. Afsenderen matches kun mod leverandører.</div>
`;
} else if (email.customer_id) {
customerHtml = `
<div class="fw-semibold text-success">${escapeHtml(email.customer_name || `Kunde #${email.customer_id}`)}</div>
<div class="small text-muted">Allerede tilknyttet denne email</div>
`;
} else if (customerSuggestion?.customer_id) {
customerHtml = `
<div class="fw-semibold">${escapeHtml(customerSuggestion.customer_name || `Kunde #${customerSuggestion.customer_id}`)}</div>
<div class="small text-muted">${escapeHtml(customerSuggestion.confidence || 'ukendt')} sikkerhed · ${escapeHtml(customerSuggestion.source || 'domænematch')}</div>
<button id="v2DecisionLinkCustomer" class="btn btn-sm btn-outline-primary mt-2">Tilknyt kunden</button>
`;
} else if (customerData) {
const proposedName = email.sender_name || senderDomain || 'Ny kunde';
customerHtml = `
<div class="fw-semibold">${escapeHtml(proposedName)}</div>
<div class="small text-muted">Ingen eksisterende kunde fundet${senderDomain ? ` for ${escapeHtml(senderDomain)}` : ''}</div>
<button id="v2DecisionCreateCustomer" class="btn btn-sm btn-primary mt-2">
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt kunde
</button>
`;
} else {
customerHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder kunde…</div>';
}
let vendorHtml;
if (email.supplier_id) {
vendorHtml = `
<div class="fw-semibold text-success">${escapeHtml(email.supplier_name || `Leverandør #${email.supplier_id}`)}</div>
<div class="small text-muted">Allerede tilknyttet denne email</div>
`;
} else if (vendorSuggestion?.vendor_id) {
vendorHtml = `
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name || `Leverandør #${vendorSuggestion.vendor_id}`)}</div>
<div class="small text-muted">Matchscore ${Number(vendorSuggestion.match_score || 0)}${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
<button id="v2DecisionLinkVendor" class="btn btn-sm btn-outline-primary mt-2">Tilknyt leverandøren</button>
`;
} else if (vendorSuggestion?.name) {
vendorHtml = `
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name)}</div>
<div class="small text-muted">Ny leverandør${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-primary mt-2">
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt leverandør
</button>
`;
} else if (vendorSuggestion) {
vendorHtml = `
<div class="small text-muted">Systemet fandt ikke en sikker leverandør.</div>
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-outline-primary mt-2">
Opret leverandør fra afsender
</button>
`;
} else {
vendorHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder leverandør…</div>';
}
return `
<div class="row g-2 mb-3">
<div class="col-12">
<div class="border rounded p-2">
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-building me-1"></i>Systemet tror kunden er</div>
${customerHtml}
</div>
</div>
<div class="col-12">
<div class="border rounded p-2">
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-truck me-1"></i>Systemet tror leverandøren er</div>
${vendorHtml}
</div>
</div>
</div>
`;
}
function bindIdentityDecisionActions() {
document.getElementById('v2DecisionLinkCustomer')?.addEventListener('click', applyDomainCustomerSuggestion);
document.getElementById('v2DecisionCreateCustomer')?.addEventListener('click', createCustomerFromSuggestion);
document.getElementById('v2DecisionLinkVendor')?.addEventListener('click', linkVendorSuggestion);
document.getElementById('v2DecisionCreateVendor')?.addEventListener('click', createVendorFromSuggestion);
}
function renderClassificationDecision(errorMessage) {
const host = document.getElementById('v2DecisionCard');
if (!host) return;
if (errorMessage) {
host.innerHTML = `
<div class="fw-semibold text-danger">Vurdering kunne ikke indlæses</div>
<div class="small text-muted mt-1">${escapeHtml(errorMessage)}</div>
`;
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Systemet vurderer mailen…</div>';
return;
}
const meta = preview.email || {};
const classification = String(meta.classification || 'unknown').toLowerCase();
const typeLabel = CLASSIFICATION_LABELS[classification] || classification;
const rawConfidence = Number(meta.confidence_score || 0);
const confidencePercent = Math.round(Math.max(0, Math.min(1, rawConfidence)) * 100);
const confidenceLabel = confidencePercent >= 80
? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt';
const email = state.selectedEmail || {};
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
const effects = [];
if (isApprovedSupplierInvoice) {
effects.push(
'Beholder tilknytningen til den valgte leverandør',
'Henter PDF-bilaget og sender det til Leverandørfakturaer',
'Aflæser fakturanummer, beløb og varelinjer',
'Opdaterer Internetforbindelser automatisk, når fakturaen er fra GlobalConnect',
'Markerer emailen som behandlet'
);
} else {
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
(preview.matching_workflows || []).forEach((workflow) => {
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
actions.forEach((action) => effects.push(
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}`
));
});
}
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Anbefalet behandling</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
</div>
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Vælg flowet øverst på siden.</div>
<div class="mt-3">
<div class="small fw-semibold mb-2">Vurdering og handlinger</div>
<div class="mt-3">
${renderIdentityAssessment()}
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-2">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : ''}
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
</div>
</div>
`;
bindIdentityDecisionActions();
}
async function approveSuggestedActions() {
const button = document.getElementById('v2ApproveActions');
if (!button || !state.selectedEmailId) return;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try {
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
if (classification === 'invoice' && state.selectedEmail?.supplier_id) {
await runQuickAction('create_supplier_invoice', button);
} else {
await executeWorkflowsCurrent();
}
} finally {
if (document.body.contains(button)) {
button.disabled = false;
button.innerHTML = '<i class="bi bi-check-circle me-2"></i>Godkend og udfør';
}
}
}
function renderWorkflowPreview(errorMessage) {
const host = document.getElementById('v2WorkflowPreview');
if (!host) return;
if (errorMessage) {
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
renderClassificationDecision(errorMessage);
return;
}
const preview = state.workflowPreview;
if (!preview) {
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
renderClassificationDecision();
return;
}
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
const emailMeta = preview.email || {};
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
&& Boolean(state.selectedEmail?.supplier_id);
const systemHtml = system.map((row) => {
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
return `
<div class="emails-v2-kv">
<div class="k">${escapeHtml(row.name || row.code || 'System')}</div>
<div class="v">${badge} <span class="small text-muted">${escapeHtml(row.reason || '')}</span></div>
</div>
`;
}).join('');
const matchingHtml = usesSupplierApprovalFlow
? `<div class="emails-v2-kv">
<div class="k">Godkendelsesflow</div>
<div class="v">
<span class="badge bg-success">Leverandørfaktura</span>
<span class="small text-muted">PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet</span>
</div>
</div>`
: matching.length
? matching.map((wf) => `
<div class="emails-v2-kv">
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
<div class="v">
<span class="badge bg-success">Matcher</span>
<span class="small text-muted">prio ${escapeHtml(String(wf.priority ?? '-'))} • min conf ${escapeHtml(String(wf.confidence_threshold ?? '-'))} • steps ${escapeHtml(String(wf.steps_total ?? 0))}</span>
</div>
</div>
`).join('')
: '<div class="small text-muted">Ingen bruger-workflows matcher aktuelt.</div>';
host.innerHTML = `
<div class="emails-v2-kv"><div class="k">Klassifikation</div><div class="v">${escapeHtml(emailMeta.classification || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Confidence</div><div class="v">${escapeHtml(String(emailMeta.confidence_score ?? 0))}</div></div>
<div class="mt-2">
<div class="small fw-semibold mb-1">Systemflows</div>
${systemHtml || '<div class="small text-muted">Ingen systemflows</div>'}
</div>
<div class="mt-2">
<div class="small fw-semibold mb-1">Workflow match</div>
${matchingHtml}
</div>
`;
renderClassificationDecision();
const autoRunBtn = document.getElementById('v2AutoRun');
if (autoRunBtn) {
const enabled = Boolean(preview.auto_run_enabled);
autoRunBtn.disabled = !enabled;
autoRunBtn.title = enabled
? 'Autokør er aktiv'
: 'Aktiveres senere via EMAIL_WORKFLOW_AUTORUN_ENABLED=true';
}
}
async function autoParseAndLinkCurrent() {
if (!state.selectedEmailId) return;
try {
setDetailStatus('Auto parser email til tråd/sag...');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/execute-workflows`, {
method: 'POST',
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/reprocess`, {
method: 'POST',
});
await selectEmail(state.selectedEmailId);
setDetailStatus('Auto parse gennemført');
} catch (error) {
setDetailStatus(`Auto parse fejlede: ${error.message}`);
}
}
async function createCaseFromCurrent() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const caseTypeEl = document.getElementById('v2CaseType');
const titelEl = document.getElementById('v2CaseTitle');
const createButton = document.getElementById('v2CreateCase');
const payload = {
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
case_type: String(caseTypeEl?.value || 'support'),
};
if (state.selectedEmail.customer_id) {
payload.customer_id = Number(state.selectedEmail.customer_id);
}
try {
if (createButton) {
createButton.disabled = true;
createButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter…';
}
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const sagId = Number(result?.sag?.id || result?.sag_id || 0);
setDetailStatus(sagId ? `SAG #${sagId} oprettet` : 'SAG oprettet');
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette sag: ${error.message}`);
window.alert(`Sagen kunne ikke oprettes:\n${error.message}`);
} finally {
if (createButton && document.body.contains(createButton)) {
createButton.disabled = false;
createButton.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Opret';
}
}
}
async function runQuickAction(action, button) {
if (!state.selectedEmailId || !state.selectedEmail || !action) return;
const originalHtml = button?.innerHTML || '';
try {
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Udfører…';
}
const title = String(document.getElementById('v2CaseTitle')?.value
|| state.selectedEmail.subject
|| '').trim();
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/quick-action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, titel: title }),
});
if (action === 'archive_entity_mail') {
state.selectedEmailId = null;
state.selectedEmail = null;
renderDetail(null);
await loadEmails();
} else {
await selectEmail(state.selectedEmailId);
}
setDetailStatus(result?.message || 'Handlingen er udført');
} catch (error) {
setDetailStatus(`Handlingen fejlede: ${error.message}`);
window.alert(`Handlingen kunne ikke udføres:\n${error.message}`);
} finally {
if (button && document.body.contains(button)) {
button.disabled = false;
button.innerHTML = originalHtml;
}
}
}
async function linkSelectedSag(sagId) {
if (!state.selectedEmailId || !sagId) return;
try {
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sag_id: Number(sagId), relation_type: 'mail', mark_processed: true }),
});
setDetailStatus(`Email linket til SAG #${sagId}`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke linke sag: ${error.message}`);
}
}
function setSupplierStatus(message, type) {
const el = document.getElementById('v2SupplierStatus');
if (!el) return;
el.className = `emails-v2-inline-status ${type || ''}`.trim();
el.textContent = message || '';
}
function renderVendorSuggestion() {
const host = document.getElementById('v2VendorSuggestion');
if (!host) return;
const suggestion = state.vendorSuggestion;
if (!suggestion) {
host.innerHTML = '<div class="small text-muted">Ingen forslag endnu</div>';
return;
}
host.innerHTML = `
<div class="emails-v2-kv"><div class="k">Navn</div><div class="v">${escapeHtml(suggestion.name || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(suggestion.cvr_number || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Adresse</div><div class="v">${escapeHtml(suggestion.address || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Telefon</div><div class="v">${escapeHtml(suggestion.phone || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Email</div><div class="v">${escapeHtml(suggestion.email || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Domæne</div><div class="v">${escapeHtml(suggestion.domain || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Match</div><div class="v">Vendor ID: ${escapeHtml(String(suggestion.vendor_id || '-'))} • Score: ${escapeHtml(String(suggestion.match_score || 0))}</div></div>
`;
}
async function extractVendorSuggestionCurrent() {
if (!state.selectedEmailId) return;
try {
setSupplierStatus('Udtrækker leverandørforslag...');
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/extract-vendor-suggestion`, {
method: 'POST',
});
state.vendorSuggestion = suggestion || null;
renderVendorSuggestion();
renderClassificationDecision();
setSupplierStatus('Forslag opdateret', 'success');
} catch (error) {
state.vendorSuggestion = {};
renderClassificationDecision();
setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error');
}
}
function setDomainStatus(message, type) {
const el = document.getElementById('v2DomainStatus');
if (!el) return;
el.className = `emails-v2-inline-status ${type || ''}`.trim();
el.textContent = message || '';
}
function renderDomainCustomerSuggestion() {
const host = document.getElementById('v2DomainSuggestion');
if (!host) return;
const data = state.domainCustomerSuggestion;
if (!data) {
host.innerHTML = '<div class="small text-muted">Ingen domæneforslag endnu</div>';
return;
}
if (data.has_customer) {
host.innerHTML = '<div class="small text-success">Email har allerede kunde-link.</div>';
return;
}
if (data.ignored) {
host.innerHTML = `<div class="small text-muted">Forslag ignoreret (${escapeHtml(data.reason || 'ukendt')})</div>`;
return;
}
if (!data.suggestion) {
host.innerHTML = '<div class="small text-muted">Ingen match på domæne</div>';
return;
}
host.innerHTML = `
<div class="emails-v2-kv"><div class="k">Domæne</div><div class="v">${escapeHtml(data.domain || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Kunde</div><div class="v">${escapeHtml(data.suggestion.customer_name || '-')} (#${escapeHtml(String(data.suggestion.customer_id || '-'))})</div></div>
<div class="emails-v2-kv"><div class="k">Sikkerhed</div><div class="v">${escapeHtml(data.suggestion.confidence || '-')} • score ${escapeHtml(String(data.suggestion.score || 0))}</div></div>
<div class="emails-v2-kv"><div class="k">Kilde</div><div class="v">${escapeHtml(data.suggestion.source || '-')}</div></div>
<button id="v2ApplyDomainSuggestion" class="btn btn-sm btn-outline-primary mt-2">Anvend kunde-link</button>
`;
document.getElementById('v2ApplyDomainSuggestion')?.addEventListener('click', applyDomainCustomerSuggestion);
}
async function loadDomainCustomerSuggestionCurrent() {
if (!state.selectedEmailId) return;
try {
setDomainStatus('Henter domæneforslag...');
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`);
state.domainCustomerSuggestion = suggestion || null;
renderDomainCustomerSuggestion();
renderClassificationDecision();
setDomainStatus('Forslag opdateret', 'success');
} catch (error) {
state.domainCustomerSuggestion = {
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
suggestion: null,
};
renderClassificationDecision();
setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error');
}
}
async function applyDomainCustomerSuggestion() {
if (!state.selectedEmailId) return;
const suggestion = state.domainCustomerSuggestion?.suggestion;
if (!suggestion?.customer_id) return;
try {
setDomainStatus('Anvender kunde-link...');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: Number(suggestion.customer_id) }),
});
await selectEmail(state.selectedEmailId);
setDomainStatus('Kunde-link anvendt', 'success');
} catch (error) {
setDomainStatus(`Kunne ikke anvende forslag: ${error.message}`, 'error');
}
}
async function createCustomerFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const domain = state.domainCustomerSuggestion?.domain
|| String(email.sender_email || '').split('@')[1]
|| null;
const name = String(email.sender_name || domain || '').trim();
if (!name) {
setDetailStatus('Kunden kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter kunden ${name}`);
const customer = await apiFetch('/api/v1/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email: email.sender_email || null,
email_domain: domain,
is_active: true,
country: 'DK',
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: Number(customer.id) }),
});
setDetailStatus(`Kunden ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette kunden: ${error.message}`);
}
}
async function linkVendorSuggestion() {
const vendorId = Number(state.vendorSuggestion?.vendor_id || 0);
if (!state.selectedEmailId || !vendorId) return;
try {
setDetailStatus('Tilknytter leverandøren…');
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: vendorId }),
});
setDetailStatus('Leverandøren blev tilknyttet');
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke tilknytte leverandøren: ${error.message}`);
}
}
async function createVendorFromSuggestion() {
if (!state.selectedEmailId || !state.selectedEmail) return;
const email = state.selectedEmail;
const suggestion = state.vendorSuggestion || {};
const senderDomain = String(email.sender_email || '').split('@')[1] || null;
const name = String(suggestion.name || email.sender_name || senderDomain || '').trim();
if (!name) {
setDetailStatus('Leverandøren kan ikke oprettes uden et navn');
return;
}
try {
setDetailStatus(`Opretter leverandøren ${name}`);
const vendor = await apiFetch('/api/v1/vendors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
cvr_number: suggestion.cvr_number || null,
email: suggestion.email || email.sender_email || null,
phone: suggestion.phone || null,
address: suggestion.address || null,
domain: suggestion.domain || senderDomain,
category: 'supplier',
notes: `Oprettet fra email #${state.selectedEmailId}`,
is_active: true,
}),
});
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: Number(vendor.id) }),
});
setDetailStatus(`Leverandøren ${name} blev oprettet og tilknyttet`);
await selectEmail(state.selectedEmailId);
} catch (error) {
setDetailStatus(`Kunne ikke oprette leverandøren: ${error.message}`);
}
}
async function searchSager(query) {
const 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`);
const list = Array.isArray(rows) ? rows : [];
if (!list.length) {
host.innerHTML = '<div class="p-2 small text-muted">Ingen sager fundet</div>';
return;
}
host.innerHTML = list.map((item) => `
<div class="emails-v2-sag-result" data-sag-id="${Number(item.id)}">
<div class="fw-semibold">#${Number(item.id)} ${escapeHtml(item.titel || 'Uden titel')}</div>
<div class="small text-muted">${escapeHtml(item.customer_name || 'Ukendt kunde')}${escapeHtml(item.status || '-')}</div>
</div>
`).join('');
host.querySelectorAll('[data-sag-id]').forEach((node) => {
node.addEventListener('click', () => {
linkSelectedSag(Number(node.getAttribute('data-sag-id')));
});
});
} catch (error) {
host.innerHTML = `<div class="p-2 small text-danger">Fejl: ${escapeHtml(error.message)}</div>`;
}
}
function renderDetail(email) {
const mailHeader = document.getElementById('v2MailHeader');
const quickActions = document.getElementById('v2QuickActions');
const mailBody = document.getElementById('v2MailBody');
const sideActions = document.getElementById('v2SideActions');
if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
if (!email) {
mailHeader.innerHTML = 'Vælg en email for at se info';
quickActions.innerHTML = '';
mailBody.className = 'emails-v2-detail-empty';
mailBody.textContent = 'Vælg en email fra listen';
sideActions.className = 'emails-v2-detail-empty';
sideActions.textContent = 'Vælg en email for handlinger';
setMailStatus('Ingen email valgt');
return;
}
const headerLink = email.linked_case_id
? `<a href="/sag/${Number(email.linked_case_id)}/v3">SAG #${Number(email.linked_case_id)}</a>`
: 'Ikke linket til sag';
mailHeader.innerHTML = `
<div class="emails-v2-subject">${escapeHtml(email.subject || '(Ingen emne)')}</div>
<div class="emails-v2-topmeta mb-0">
<span>Fra: ${escapeHtml(email.sender_name || email.sender_email || '-')}</span>
<span>Modtaget: ${escapeHtml(formatDate(email.received_date))}</span>
<span>Status: ${escapeHtml(email.status || '-')}</span>
<span>${headerLink}</span>
</div>
`;
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
const hasIdentity = hasCustomer || hasSupplier;
const supplierIdentity = `${email.supplier_name || ''} ${email.sender_email || ''}`.toLowerCase();
const isGlobalConnect = supplierIdentity.includes('globalconnect');
const classification = String(email.classification || '').toLowerCase();
const recommendedFlow = classification === 'invoice'
? 'create_supplier_invoice'
: (classification === 'customer_email' ? 'create_customer_case' : '');
const recommendationBadge = (flow) => recommendedFlow === flow
? '<span class="flow-badge">ANBEFALET</span>'
: '';
const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : '';
quickActions.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
` : `
<div class="emails-v2-flow-picker">
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow">
<button class="emails-v2-flow-choice flow-support${recommendationClass('create_customer_case')}" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
${recommendationBadge('create_customer_case')}
<span class="flow-title"><i class="bi bi-headset me-1"></i>Support</span>
<span class="flow-description">Opret en almindelig kundesag</span>
</button>
<button class="emails-v2-flow-choice flow-invoice${recommendationClass('create_supplier_invoice')}" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
${recommendationBadge('create_supplier_invoice')}
<span class="flow-title"><i class="bi bi-receipt me-1"></i>Leverandørfaktura</span>
<span class="flow-description">${isGlobalConnect ? 'Internetfaktura genkendt · opdaterer Internetforbindelser' : 'Aflæs PDF og kør leverandørflowet'}</span>
</button>
<button class="emails-v2-flow-choice flow-accounting${recommendationClass('create_accounting_case')}" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
${recommendationBadge('create_accounting_case')}
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
<span class="flow-description">Opret kundesag hos Bogholderi</span>
</button>
<button class="emails-v2-flow-choice flow-archive${recommendationClass('archive_entity_mail')}" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
${recommendationBadge('archive_entity_mail')}
<span class="flow-title"><i class="bi bi-archive me-1"></i>Kun arkivér</span>
<span class="flow-description">Ingen sag eller anden behandling</span>
</button>
</div>
</div>
`;
mailBody.className = 'emails-v2-mail-body';
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
const rawHtml = String(email.body_html || '').trim();
const sanitizedHtml = sanitizeEmailHtml(rawHtml);
const renderedMessage = sanitizedHtml || formatEmailPlainText(email.body_text || 'Ingen indhold');
const messageClass = sanitizedHtml ? 'emails-v2-body html' : 'emails-v2-body';
mailBody.innerHTML = `
<div class="emails-v2-card">
<h6>Besked</h6>
<div class="${messageClass}">${renderedMessage}</div>
</div>
${hasAttachments ? `
<div class="emails-v2-card">
<h6>Vedhæftninger (${email.attachments.length})</h6>
<div class="emails-v2-attachments">
${email.attachments.map((att) => {
const name = att.filename || `Vedhæftning ${att.id}`;
return `<a class="btn btn-sm btn-outline-secondary" href="/api/v1/emails/${Number(email.id)}/attachments/${Number(att.id)}">${escapeHtml(name)}</a>`;
}).join('')}
</div>
</div>` : ''}
`;
sideActions.className = 'emails-v2-actions-pane';
sideActions.innerHTML = `
<div id="v2DecisionCard" class="email-ai-decision">
<div class="d-flex align-items-center gap-2 text-muted">
<span class="spinner-border spinner-border-sm"></span>
Systemet vurderer mailtype og handlinger…
</div>
</div>
<details class="emails-v2-card">
<summary class="fw-semibold" style="cursor:pointer;">
<i class="bi bi-three-dots me-1"></i>Flere handlinger
</summary>
<div class="mt-3">
${email.linked_case_id ? `
<div class="emails-primary-action rounded p-3 mb-3">
<h6>Allerede knyttet til sag</h6>
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
<button id="v2Processed" class="btn btn-sm btn-outline-success w-100 mt-2">
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
</button>
</div>` : `
<details class="border rounded p-3 mb-3">
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary>
<div class="mt-3">
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm">
<option value="support" ${!email.supplier_id ? 'selected' : ''}>Support</option>
<option value="bogholderi">Bogholderi</option>
<option value="leverandor" ${email.supplier_id ? 'selected' : ''}>Leverandør</option>
<option value="helhedsopgave">Projekt/helhedsopgave</option>
<option value="andet">Andet</option>
</select>
<button id="v2CreateCase" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-lg me-1"></i>Opret
</button>
</div>
</div>
</details>`}
<div class="border rounded p-3 mb-3">
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
<div id="v2SagResults" class="emails-v2-sag-results"></div>
</div>
<div class="border rounded p-3 mb-3">
<h6>Hurtige mailhandlinger</h6>
<div class="emails-v2-actions mb-0">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-envelope${email.is_read ? '' : '-open'} me-1"></i>${email.is_read ? 'Ulæst' : 'Læst'}
</button>
<button id="v2Archive" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-archive me-1"></i>Arkivér
</button>
${email.linked_case_id ? '' : `
<button id="v2Processed" class="btn btn-sm btn-outline-success">
<i class="bi bi-check2 me-1"></i>Behandlet
</button>`}
</div>
</div>
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
<button id="v2AutoParse" class="btn btn-sm btn-outline-primary">Find tråd/sag automatisk</button>
</div>
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details>
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Beløb</div><div class="v">${escapeHtml(String(email.extracted_amount || '-'))}</div></div>
<div class="emails-v2-kv"><div class="k">Forfald</div><div class="v">${escapeHtml(email.extracted_due_date || '-')}</div></div>
<div class="emails-v2-right-actions mt-2">
<button id="v2ExtractVendor" class="btn btn-sm btn-outline-warning">Udtræk leverandørforslag</button>
</div>
<div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div>
<div id="v2SupplierStatus" class="emails-v2-inline-status"></div>
<hr>
<h6>Automatisk kundematch</h6>
<div class="emails-v2-right-actions">
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
</div>
<div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div>
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</details>
<details class="border rounded p-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
email_id: email.id,
message_id: email.message_id,
in_reply_to: email.in_reply_to,
email_references: email.email_references,
classification: email.classification,
confidence_score: email.confidence_score,
linked_case_id: email.linked_case_id,
}, null, 2))}</pre>
</details>
</div>
</details>
`;
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));
document.getElementById('v2Archive')?.addEventListener('click', archiveCurrent);
document.getElementById('v2Processed')?.addEventListener('click', markProcessedCurrent);
document.getElementById('v2Reprocess')?.addEventListener('click', reprocessCurrent);
document.getElementById('v2ExecuteWorkflows')?.addEventListener('click', executeWorkflowsCurrent);
document.getElementById('v2AutoRun')?.addEventListener('click', autoRunWorkflowsCurrent);
document.getElementById('v2AutoParse')?.addEventListener('click', autoParseAndLinkCurrent);
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent);
document.querySelectorAll('[data-v2-quick-action]').forEach((button) => {
button.addEventListener('click', () => {
runQuickAction(button.getAttribute('data-v2-quick-action'), button);
});
});
state.vendorSuggestion = null;
state.domainCustomerSuggestion = null;
state.workflowPreview = null;
renderVendorSuggestion();
renderDomainCustomerSuggestion();
renderWorkflowPreview();
loadWorkflowPreviewCurrent();
const vendorClassifications = new Set(['invoice', 'order_confirmation', 'freight_note']);
if (email.supplier_id || vendorClassifications.has(String(email.classification || '').toLowerCase())
|| email.extracted_vendor_name || email.extracted_vendor_cvr) {
extractVendorSuggestionCurrent();
} else {
state.vendorSuggestion = {};
renderClassificationDecision();
}
setMailStatus(`Email #${email.id} vises`);
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
const query = event.target.value;
clearTimeout(state.sagSearchTimer);
state.sagSearchTimer = setTimeout(() => searchSager(query), 220);
});
}
async function uploadEmailFiles(files) {
const accepted = Array.from(files || []).filter((file) =>
/\.(eml|msg)$/i.test(file.name || '')
);
if (!accepted.length) {
setListStatus('Vælg .eml- eller .msg-filer');
return;
}
const zone = document.getElementById('v2UploadZone');
const label = document.getElementById('v2UploadLabel');
const original = label?.innerHTML;
if (zone) {
zone.classList.add('busy');
}
if (label) {
label.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>Importerer ${accepted.length} mail${accepted.length === 1 ? '' : 's'}`;
}
try {
const formData = new FormData();
accepted.forEach((file) => formData.append('files', file));
const result = await apiFetch('/api/v1/emails/upload', {
method: 'POST',
body: formData,
});
const uploaded = Number(result?.uploaded || 0);
const duplicates = Number(result?.duplicates || 0);
setListStatus(`${uploaded} importeret${duplicates ? ` · ${duplicates} fandtes allerede` : ''}`);
state.filter = 'active';
state.selectedEmailId = null;
await loadEmails();
} catch (error) {
setListStatus(`Import fejlede: ${error.message}`);
} finally {
if (zone) {
zone.classList.remove('busy', 'dragover');
}
if (label) label.innerHTML = original;
}
}
function setupUploadZone() {
const zone = document.getElementById('v2UploadZone');
const input = document.getElementById('v2UploadInput');
if (!zone || !input || zone.dataset.ready === 'true') return;
zone.dataset.ready = 'true';
zone.addEventListener('click', () => input.click());
zone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
input.click();
}
});
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.add('dragover');
}));
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.remove('dragover');
}));
zone.addEventListener('drop', (event) => uploadEmailFiles(event.dataTransfer?.files));
input.addEventListener('change', () => {
uploadEmailFiles(input.files);
input.value = '';
});
}
function selectAdjacentEmail(offset) {
if (!state.emails.length) return;
const current = state.emails.findIndex((email) => Number(email.id) === Number(state.selectedEmailId));
const next = Math.min(Math.max((current < 0 ? 0 : current) + offset, 0), state.emails.length - 1);
selectEmail(Number(state.emails[next].id));
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (event) => {
const target = event.target;
const typing = target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable;
if (event.key === '/' && !typing) {
event.preventDefault();
document.getElementById('v2Search')?.focus();
} else if (!typing && event.key.toLowerCase() === 'j') {
selectAdjacentEmail(1);
} else if (!typing && event.key.toLowerCase() === 'k') {
selectAdjacentEmail(-1);
} else if (!typing && event.key.toLowerCase() === 'r') {
loadEmails();
}
});
}
function setupEvents() {
document.getElementById('v2Search')?.addEventListener('input', (event) => {
const value = String(event.target.value || '').trim();
clearTimeout(state.searchTimer);
state.searchTimer = setTimeout(() => {
state.query = value;
loadEmails();
}, 250);
});
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
setupUploadZone();
setupKeyboardShortcuts();
}
document.addEventListener('DOMContentLoaded', async () => {
setupEvents();
renderFilters();
renderDetail(null);
await loadEmails();
});
})();
</script>
{% endblock %}