bmc_hub/app/emails/frontend/emails_v2.html

2045 lines
82 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Email - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.emails-v2-shell {
display: grid;
grid-template-columns: 340px minmax(0, 1fr) 420px;
gap: 1rem;
height: calc(100vh - 140px);
min-height: 620px;
}
.emails-v2-panel {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
min-width: 0;
}
.emails-v2-header {
padding: 0.9rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.emails-v2-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.emails-v2-version-nav {
display: flex;
gap: 0.5rem;
}
.emails-v2-search {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--border-color);
}
.emails-quick-upload {
margin: 0.65rem 0.9rem;
padding: 0.55rem 0.7rem;
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 45%, var(--border-color));
border-radius: 9px;
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
text-align: center;
transition: 0.15s ease;
}
.emails-quick-upload:hover,
.emails-quick-upload.dragover {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent, #0f4c75) 9%, var(--bg-card));
}
.emails-quick-upload.busy {
pointer-events: none;
opacity: 0.65;
}
.emails-primary-action {
border: 1px solid color-mix(in srgb, var(--accent, #0f4c75) 35%, var(--border-color));
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-flow-picker {
width: 100%;
}
.emails-v2-flow-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 0.65rem;
}
.emails-v2-flow-choice {
position: relative;
min-height: 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 %}