1288 lines
47 KiB
HTML
1288 lines
47 KiB
HTML
|
|
{% extends "shared/frontend/base.html" %}
|
||
|
|
|
||
|
|
{% block title %}Email v2 - 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-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-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">Email v2</h1>
|
||
|
|
<div class="emails-v2-version-nav">
|
||
|
|
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Gå til v1</a>
|
||
|
|
<a href="/emails/v2" class="btn btn-sm btn-primary" aria-current="page">v2</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="emails-v2-search">
|
||
|
|
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg afsender eller emne...">
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="v2Filters" class="emails-v2-filters"></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>
|
||
|
|
<div class="d-flex gap-2">
|
||
|
|
<button id="v2FetchTest" class="btn btn-sm btn-outline-success">Hent fra test-mappe</button>
|
||
|
|
<button id="v2Refresh" class="btn btn-sm btn-outline-primary">Opdater</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</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>
|
||
|
|
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Sammenlign v1</a>
|
||
|
|
</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 TEST_MAILBOX_FOLDER = 'BMC_TEST';
|
||
|
|
|
||
|
|
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: TEST_MAILBOX_FOLDER,
|
||
|
|
searchTimer: null,
|
||
|
|
sagSearchTimer: null,
|
||
|
|
vendorSuggestion: null,
|
||
|
|
domainCustomerSuggestion: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
function escapeHtml(value) {
|
||
|
|
return String(value ?? '')
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''');
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatEmailPlainText(value) {
|
||
|
|
return escapeHtml(value || '').replace(/\n/g, '<br>');
|
||
|
|
}
|
||
|
|
|
||
|
|
function isSafeUrl(url) {
|
||
|
|
if (!url) return false;
|
||
|
|
const normalized = String(url).trim().toLowerCase();
|
||
|
|
return normalized.startsWith('http://')
|
||
|
|
|| normalized.startsWith('https://')
|
||
|
|
|| normalized.startsWith('mailto:')
|
||
|
|
|| normalized.startsWith('tel:')
|
||
|
|
|| normalized.startsWith('/');
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeEmailHtml(unsafeHtml) {
|
||
|
|
const input = String(unsafeHtml || '').trim();
|
||
|
|
if (!input) return '';
|
||
|
|
|
||
|
|
const allowedTags = new Set([
|
||
|
|
'a', 'b', 'strong', 'i', 'em', 'u', 's', 'br', 'p', 'div', 'span',
|
||
|
|
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr',
|
||
|
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||
|
|
'table', 'thead', 'tbody', 'tr', 'th', 'td'
|
||
|
|
]);
|
||
|
|
const allowedAttrs = {
|
||
|
|
a: new Set(['href', 'title', 'target', 'rel']),
|
||
|
|
th: new Set(['colspan', 'rowspan']),
|
||
|
|
td: new Set(['colspan', 'rowspan']),
|
||
|
|
};
|
||
|
|
|
||
|
|
const parser = new DOMParser();
|
||
|
|
const doc = parser.parseFromString(input, 'text/html');
|
||
|
|
|
||
|
|
const cleanNode = (node) => {
|
||
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
||
|
|
return document.createTextNode(node.textContent || '');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||
|
|
return document.createTextNode('');
|
||
|
|
}
|
||
|
|
|
||
|
|
const tag = node.tagName.toLowerCase();
|
||
|
|
if (!allowedTags.has(tag)) {
|
||
|
|
const fragment = document.createDocumentFragment();
|
||
|
|
Array.from(node.childNodes).forEach((child) => {
|
||
|
|
fragment.appendChild(cleanNode(child));
|
||
|
|
});
|
||
|
|
return fragment;
|
||
|
|
}
|
||
|
|
|
||
|
|
const el = document.createElement(tag);
|
||
|
|
const tagAllowedAttrs = allowedAttrs[tag] || new Set();
|
||
|
|
|
||
|
|
Array.from(node.attributes).forEach((attr) => {
|
||
|
|
const name = attr.name.toLowerCase();
|
||
|
|
const value = attr.value || '';
|
||
|
|
if (!tagAllowedAttrs.has(name)) return;
|
||
|
|
|
||
|
|
if (tag === 'a' && name === 'href') {
|
||
|
|
if (!isSafeUrl(value)) return;
|
||
|
|
el.setAttribute('href', value);
|
||
|
|
el.setAttribute('target', '_blank');
|
||
|
|
el.setAttribute('rel', 'noopener noreferrer');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ((name === 'colspan' || name === 'rowspan')) {
|
||
|
|
const num = Number(value);
|
||
|
|
if (!Number.isInteger(num) || num < 1 || num > 100) return;
|
||
|
|
el.setAttribute(name, String(num));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
el.setAttribute(name, value);
|
||
|
|
});
|
||
|
|
|
||
|
|
Array.from(node.childNodes).forEach((child) => {
|
||
|
|
el.appendChild(cleanNode(child));
|
||
|
|
});
|
||
|
|
return el;
|
||
|
|
};
|
||
|
|
|
||
|
|
const wrapper = document.createElement('div');
|
||
|
|
Array.from(doc.body.childNodes).forEach((child) => {
|
||
|
|
wrapper.appendChild(cleanNode(child));
|
||
|
|
});
|
||
|
|
return wrapper.innerHTML;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDate(value) {
|
||
|
|
if (!value) return '-';
|
||
|
|
const d = new Date(value);
|
||
|
|
if (Number.isNaN(d.getTime())) return '-';
|
||
|
|
return d.toLocaleString('da-DK');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function apiFetch(url, options = {}) {
|
||
|
|
const response = await fetch(url, { credentials: 'include', ...options });
|
||
|
|
if (!response.ok) {
|
||
|
|
let detail = `HTTP ${response.status}`;
|
||
|
|
try {
|
||
|
|
const payload = await response.json();
|
||
|
|
detail = payload?.detail || detail;
|
||
|
|
} catch (_) {
|
||
|
|
const text = await response.text().catch(() => '');
|
||
|
|
if (text) detail = text;
|
||
|
|
}
|
||
|
|
throw new Error(detail);
|
||
|
|
}
|
||
|
|
const contentType = response.headers.get('content-type') || '';
|
||
|
|
if (contentType.includes('application/json')) {
|
||
|
|
return response.json();
|
||
|
|
}
|
||
|
|
return response.text();
|
||
|
|
}
|
||
|
|
|
||
|
|
function setListStatus(message) {
|
||
|
|
const el = document.getElementById('v2ListStatus');
|
||
|
|
if (el) el.textContent = message || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function setDetailStatus(message) {
|
||
|
|
const el = document.getElementById('v2DetailStatus');
|
||
|
|
if (el) el.textContent = message || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function setMailStatus(message) {
|
||
|
|
const el = document.getElementById('v2MailStatus');
|
||
|
|
if (el) el.textContent = message || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderFilters() {
|
||
|
|
const host = document.getElementById('v2Filters');
|
||
|
|
if (!host) return;
|
||
|
|
|
||
|
|
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 : [];
|
||
|
|
|
||
|
|
if (state.filter === 'active' && !state.query) {
|
||
|
|
emails = emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase()));
|
||
|
|
}
|
||
|
|
|
||
|
|
state.emails = emails;
|
||
|
|
renderFilters();
|
||
|
|
renderList();
|
||
|
|
setListStatus(`${emails.length} emails vist (mappe: ${state.folder || 'alle'})`);
|
||
|
|
|
||
|
|
if (state.selectedEmailId) {
|
||
|
|
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
|
||
|
|
if (stillExists) {
|
||
|
|
await selectEmail(state.selectedEmailId, { 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;
|
||
|
|
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}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderWorkflowPreview(errorMessage) {
|
||
|
|
const host = document.getElementById('v2WorkflowPreview');
|
||
|
|
if (!host) return;
|
||
|
|
|
||
|
|
if (errorMessage) {
|
||
|
|
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const preview = state.workflowPreview;
|
||
|
|
if (!preview) {
|
||
|
|
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
|
||
|
|
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 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 = 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>
|
||
|
|
`;
|
||
|
|
|
||
|
|
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 fetchFromTestFolder() {
|
||
|
|
try {
|
||
|
|
setDetailStatus('Henter nye emails fra test-mappe...');
|
||
|
|
await apiFetch(`/api/v1/emails/process?folder=${encodeURIComponent(state.folder)}&limit=50`, {
|
||
|
|
method: 'POST',
|
||
|
|
});
|
||
|
|
await loadEmails();
|
||
|
|
setDetailStatus(`Import fuldført fra ${state.folder}`);
|
||
|
|
} catch (error) {
|
||
|
|
setDetailStatus(`Kunne ikke hente fra test-mappe: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createCaseFromCurrent() {
|
||
|
|
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||
|
|
|
||
|
|
const caseTypeEl = document.getElementById('v2CaseType');
|
||
|
|
const titelEl = document.getElementById('v2CaseTitle');
|
||
|
|
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 {
|
||
|
|
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}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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();
|
||
|
|
setSupplierStatus('Forslag opdateret', 'success');
|
||
|
|
} catch (error) {
|
||
|
|
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();
|
||
|
|
setDomainStatus('Forslag opdateret', 'success');
|
||
|
|
} catch (error) {
|
||
|
|
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 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 mailBody = document.getElementById('v2MailBody');
|
||
|
|
const sideActions = document.getElementById('v2SideActions');
|
||
|
|
if (!mailHeader || !mailBody || !sideActions) return;
|
||
|
|
|
||
|
|
if (!email) {
|
||
|
|
mailHeader.innerHTML = 'Vælg en email for at se info';
|
||
|
|
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>
|
||
|
|
`;
|
||
|
|
|
||
|
|
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 class="emails-v2-card">
|
||
|
|
<h6>Hurtighandlinger</h6>
|
||
|
|
<div class="emails-v2-actions">
|
||
|
|
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">${email.is_read ? 'Marker som ulæst' : 'Marker som læst'}</button>
|
||
|
|
<button id="v2Archive" class="btn btn-sm btn-outline-primary">Arkivér</button>
|
||
|
|
<button id="v2Processed" class="btn btn-sm btn-outline-success">Markér behandlet</button>
|
||
|
|
<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-primary">Auto parse tråd/sag</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="emails-v2-card">
|
||
|
|
<h6>Workflow match-preview</h6>
|
||
|
|
<div id="v2WorkflowPreview"><div class="small text-muted">Henter preview...</div></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="emails-v2-card">
|
||
|
|
<h6>Link til eksisterende sag</h6>
|
||
|
|
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Søg sag-ID, titel eller beskrivelse...">
|
||
|
|
<div id="v2SagResults" class="emails-v2-sag-results"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="emails-v2-card">
|
||
|
|
<h6>Opret ny sag fra email</h6>
|
||
|
|
<div class="row g-2">
|
||
|
|
<div class="col-12">
|
||
|
|
<input id="v2CaseTitle" class="form-control form-control-sm" value="${escapeHtml(email.subject || '')}" placeholder="Sags titel">
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<select id="v2CaseType" class="form-select form-select-sm">
|
||
|
|
<option value="support">Support</option>
|
||
|
|
<option value="bogholderi">Bogholderi</option>
|
||
|
|
<option value="leverandor">Leverandør</option>
|
||
|
|
<option value="helhedsopgave">Helhedsopgave</option>
|
||
|
|
<option value="andet">Andet</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="emails-v2-right-actions mt-2">
|
||
|
|
<button id="v2CreateCase" class="btn btn-sm btn-primary">Opret sag</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="emails-v2-card">
|
||
|
|
<h6>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>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="emails-v2-card">
|
||
|
|
<h6>Auto match til kunde/sag</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>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<details class="emails-v2-card">
|
||
|
|
<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>
|
||
|
|
`;
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
state.vendorSuggestion = null;
|
||
|
|
state.domainCustomerSuggestion = null;
|
||
|
|
state.workflowPreview = null;
|
||
|
|
renderVendorSuggestion();
|
||
|
|
renderDomainCustomerSuggestion();
|
||
|
|
renderWorkflowPreview();
|
||
|
|
loadWorkflowPreviewCurrent();
|
||
|
|
setMailStatus(`Email #${email.id} vises`);
|
||
|
|
|
||
|
|
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
|
||
|
|
const query = event.target.value;
|
||
|
|
clearTimeout(state.sagSearchTimer);
|
||
|
|
state.sagSearchTimer = setTimeout(() => searchSager(query), 220);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
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());
|
||
|
|
document.getElementById('v2FetchTest')?.addEventListener('click', fetchFromTestFolder);
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
||
|
|
setupEvents();
|
||
|
|
renderFilters();
|
||
|
|
renderDetail(null);
|
||
|
|
await loadEmails();
|
||
|
|
});
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
{% endblock %}
|