- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements. - Added tests for email identity API to validate email linking and identity retrieval without updates. - Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios. - Created tests for email reply automation to ensure proper routing and processing of replies based on classification. - Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies. - Added tests for email sender identity to ensure correct matching of contacts and domains. - Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2473 lines
110 KiB
HTML
2473 lines
110 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
||
|
||
{% block title %}Email - BMC Hub{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.emails-v2-shell {
|
||
display: grid;
|
||
grid-template-columns: 340px minmax(0, 1fr) 420px;
|
||
gap: 1rem;
|
||
height: calc(100vh - 140px);
|
||
min-height: 620px;
|
||
}
|
||
|
||
.emails-v2-panel {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
.emails-v2-header {
|
||
padding: 0.9rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.emails-v2-title {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.emails-v2-version-nav {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.emails-v2-search {
|
||
padding: 0.75rem 0.9rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.emails-quick-upload {
|
||
margin: 0.65rem 0.9rem;
|
||
padding: 0.55rem 0.7rem;
|
||
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 45%, var(--border-color));
|
||
border-radius: 9px;
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
text-align: center;
|
||
transition: 0.15s ease;
|
||
}
|
||
|
||
.emails-quick-upload:hover,
|
||
.emails-quick-upload.dragover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 9%, var(--bg-card));
|
||
}
|
||
|
||
.emails-quick-upload.busy {
|
||
pointer-events: none;
|
||
opacity: 0.65;
|
||
}
|
||
|
||
.emails-primary-action {
|
||
border: 1px solid color-mix(in srgb, var(--accent, #0f4c75) 35%, var(--border-color));
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||
}
|
||
|
||
.emails-v2-flow-picker {
|
||
width: 100%;
|
||
}
|
||
|
||
.emails-v2-flow-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||
gap: 0.65rem;
|
||
}
|
||
|
||
.emails-v2-flow-choice {
|
||
position: relative;
|
||
min-height: 100px;
|
||
height: 100%;
|
||
padding: 0.72rem 0.8rem;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 10px;
|
||
background: #52606d;
|
||
color: #fff;
|
||
text-align: left;
|
||
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16);
|
||
}
|
||
|
||
.emails-v2-flow-choice:hover:not(:disabled) {
|
||
filter: brightness(1.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.emails-v2-flow-choice.recommended {
|
||
border-color: var(--accent);
|
||
box-shadow: inset 0 0 0 2px var(--accent);
|
||
}
|
||
|
||
.emails-v2-flow-choice.flow-support { background: #1769aa; }
|
||
.emails-v2-flow-choice.flow-invoice { background: #18864b; }
|
||
.emails-v2-flow-choice.flow-accounting { background: #d97706; }
|
||
.emails-v2-flow-choice.flow-archive { background: #59636e; }
|
||
.emails-v2-flow-choice.flow-case { background: #1769aa; }
|
||
.emails-v2-flow-choice.flow-purchase { background: #59636e; }
|
||
.emails-v2-section-heading {
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
}
|
||
.emails-v2-section-content { padding-top: 0.75rem; }
|
||
|
||
.emails-v2-flow-choice:disabled {
|
||
opacity: 0.42;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.emails-v2-flow-choice .flow-title {
|
||
display: block;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.emails-v2-flow-choice .flow-description {
|
||
display: block;
|
||
margin-top: 0.22rem;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
font-size: 0.76rem;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.emails-v2-flow-choice .flow-badge {
|
||
position: absolute;
|
||
top: -0.55rem;
|
||
right: 0.45rem;
|
||
padding: 0.12rem 0.42rem;
|
||
border-radius: 999px;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
font-size: 0.66rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
@media (max-width: 1500px) {
|
||
.emails-v2-flow-grid {
|
||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||
}
|
||
}
|
||
|
||
.email-ai-decision {
|
||
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
|
||
border-radius: 12px;
|
||
padding: 0.9rem;
|
||
margin-bottom: 0.85rem;
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||
}
|
||
|
||
.email-ai-type {
|
||
font-size: 1.15rem;
|
||
font-weight: 750;
|
||
}
|
||
|
||
.email-ai-confidence {
|
||
height: 7px;
|
||
border-radius: 99px;
|
||
overflow: hidden;
|
||
background: var(--border-color);
|
||
}
|
||
|
||
.email-ai-confidence > span {
|
||
display: block;
|
||
height: 100%;
|
||
background: var(--accent);
|
||
}
|
||
|
||
.email-ai-actions {
|
||
margin: 0;
|
||
padding-left: 1.2rem;
|
||
font-size: 0.86rem;
|
||
}
|
||
|
||
.email-ai-actions li {
|
||
margin-bottom: 0.32rem;
|
||
}
|
||
|
||
.emails-shortcuts {
|
||
font-size: 0.72rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.emails-shortcuts kbd {
|
||
font-size: 0.68rem;
|
||
}
|
||
|
||
.emails-v2-filters {
|
||
padding: 0.65rem 0.9rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.45rem;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.emails-v2-filter {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 999px;
|
||
background: var(--bg-card);
|
||
color: var(--text-secondary);
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
padding: 0.24rem 0.62rem;
|
||
}
|
||
|
||
.emails-v2-filter.active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
|
||
.emails-v2-list {
|
||
overflow: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.emails-v2-item {
|
||
border-bottom: 1px solid var(--border-color);
|
||
padding: 0.75rem 0.9rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.emails-v2-item:hover {
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 6%, var(--bg-card));
|
||
}
|
||
|
||
.emails-v2-item.active {
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 14%, var(--bg-card));
|
||
border-left: 3px solid var(--accent);
|
||
padding-left: calc(0.9rem - 3px);
|
||
}
|
||
|
||
.emails-v2-item.unread .subject {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.emails-v2-item .head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.emails-v2-item .sender {
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.emails-v2-item .time {
|
||
font-size: 0.74rem;
|
||
color: var(--text-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.emails-v2-item .subject {
|
||
margin-top: 0.2rem;
|
||
font-size: 0.88rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.emails-v2-item .meta {
|
||
margin-top: 0.28rem;
|
||
font-size: 0.75rem;
|
||
color: var(--text-secondary);
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.emails-v2-empty {
|
||
padding: 2rem 1rem;
|
||
text-align: center;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.emails-v2-detail-empty {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.emails-v2-detail {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.emails-v2-mail-header {
|
||
padding: 0.9rem 1rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||
}
|
||
|
||
.emails-v2-quick-toolbar {
|
||
padding: 0.65rem 1rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
background: var(--bg-card);
|
||
}
|
||
|
||
.emails-v2-quick-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0.45rem;
|
||
}
|
||
|
||
.emails-v2-quick-grid .btn {
|
||
min-height: 38px;
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.emails-v2-mail-body {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.emails-v2-actions-pane {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.emails-v2-subject {
|
||
font-size: 1.18rem;
|
||
font-weight: 700;
|
||
margin-bottom: 0.35rem;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.emails-v2-topmeta {
|
||
color: var(--text-secondary);
|
||
font-size: 0.84rem;
|
||
margin-bottom: 0.9rem;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.emails-v2-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.45rem;
|
||
margin-bottom: 0.95rem;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.emails-v2-right-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.emails-v2-nextstep {
|
||
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 38%, transparent);
|
||
border-radius: 10px;
|
||
padding: 0.7rem;
|
||
margin-bottom: 0.9rem;
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
|
||
}
|
||
|
||
.emails-v2-nextstep .label {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.emails-v2-card {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 10px;
|
||
padding: 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.emails-v2-card h6 {
|
||
margin: 0 0 0.5rem 0;
|
||
font-size: 0.82rem;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.emails-v2-kv {
|
||
display: grid;
|
||
grid-template-columns: 130px 1fr;
|
||
gap: 0.35rem;
|
||
font-size: 0.82rem;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.emails-v2-kv .k {
|
||
color: var(--text-secondary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.emails-v2-kv .v {
|
||
color: var(--text-primary);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.emails-v2-inline-status {
|
||
margin-top: 0.55rem;
|
||
font-size: 0.8rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.emails-v2-inline-status.error {
|
||
color: var(--theme-danger-text, #b42318);
|
||
}
|
||
|
||
.emails-v2-inline-status.success {
|
||
color: #067647;
|
||
}
|
||
|
||
.emails-v2-body {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
line-height: 1.35;
|
||
font-size: 0.92rem;
|
||
}
|
||
|
||
.emails-v2-body.html {
|
||
white-space: normal;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.emails-v2-body.html p,
|
||
.emails-v2-body.html ul,
|
||
.emails-v2-body.html ol,
|
||
.emails-v2-body.html blockquote,
|
||
.emails-v2-body.html pre,
|
||
.emails-v2-body.html table {
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.emails-v2-body.html table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.emails-v2-body.html th,
|
||
.emails-v2-body.html td {
|
||
border: 1px solid var(--border-color);
|
||
padding: 0.35rem 0.45rem;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.emails-v2-body.html a {
|
||
word-break: break-all;
|
||
}
|
||
|
||
.emails-v2-attachments {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.45rem;
|
||
}
|
||
|
||
.emails-v2-sag-results {
|
||
max-height: 220px;
|
||
overflow: auto;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.emails-v2-sag-result {
|
||
padding: 0.45rem 0.55rem;
|
||
border-bottom: 1px solid var(--border-color);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.emails-v2-sag-result:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.emails-v2-sag-result:hover {
|
||
background: color-mix(in srgb, var(--accent, #0f4c75) 8%, var(--bg-card));
|
||
}
|
||
|
||
.emails-v2-status {
|
||
padding: 0.5rem 0.9rem;
|
||
border-top: 1px solid var(--border-color);
|
||
font-size: 0.8rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.emails-v2-shell {
|
||
grid-template-columns: 1fr;
|
||
height: auto;
|
||
min-height: 0;
|
||
}
|
||
|
||
.emails-v2-panel {
|
||
min-height: 420px;
|
||
}
|
||
}
|
||
|
||
@media (min-width: 1101px) and (max-width: 1350px) {
|
||
.emails-v2-shell {
|
||
grid-template-columns: 310px minmax(0, 1fr);
|
||
height: auto;
|
||
}
|
||
|
||
.emails-v2-shell > .emails-v2-panel:last-child {
|
||
grid-column: 1 / -1;
|
||
min-height: 0;
|
||
max-height: 520px;
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid py-3">
|
||
<div class="emails-v2-shell">
|
||
<section class="emails-v2-panel">
|
||
<div class="emails-v2-header">
|
||
<h1 class="emails-v2-title"><i class="bi bi-inbox me-2"></i>Email</h1>
|
||
<button id="v2Refresh" class="btn btn-sm btn-outline-primary" title="Opdater (R)">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="emails-v2-search">
|
||
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg i mails… (tryk /)">
|
||
</div>
|
||
|
||
<div id="v2Filters" class="emails-v2-filters"></div>
|
||
|
||
<div class="emails-quick-upload" id="v2UploadZone" role="button" tabindex="0">
|
||
<span id="v2UploadLabel">
|
||
<i class="bi bi-paperclip me-1"></i>Træk .eml/.msg hertil eller klik
|
||
</span>
|
||
<input id="v2UploadInput" type="file" accept=".eml,.msg" multiple hidden>
|
||
</div>
|
||
|
||
<div id="v2List" class="emails-v2-list"></div>
|
||
|
||
<div id="v2ListStatus" class="emails-v2-status">Klar</div>
|
||
</section>
|
||
|
||
<section class="emails-v2-panel">
|
||
<div class="emails-v2-header">
|
||
<h2 class="emails-v2-title">Detalje</h2>
|
||
<span class="emails-shortcuts"><kbd>J</kbd>/<kbd>K</kbd> næste/forrige</span>
|
||
</div>
|
||
|
||
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
|
||
|
||
<div id="v2QuickActions"></div>
|
||
|
||
<div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div>
|
||
|
||
<div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
|
||
</section>
|
||
|
||
<section class="emails-v2-panel">
|
||
<div class="emails-v2-header">
|
||
<h2 class="emails-v2-title">Handlinger</h2>
|
||
<span class="small text-muted">Vælg hvad der skal ske</span>
|
||
</div>
|
||
|
||
<div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
|
||
|
||
<div id="v2DetailStatus" class="emails-v2-status">Ingen email valgt</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
(() => {
|
||
const FILTERS = [
|
||
{ key: 'active', label: 'Aktive' },
|
||
{ key: 'unread', label: 'Ulæste' },
|
||
{ key: 'awaiting_user_action', label: 'Afventer handling' },
|
||
{ key: 'processed', label: 'Behandlede' },
|
||
{ key: 'all', label: 'Alle' },
|
||
];
|
||
|
||
const state = {
|
||
emails: [],
|
||
selectedEmail: null,
|
||
selectedEmailId: null,
|
||
workflowPreview: null,
|
||
filter: new URLSearchParams(window.location.search).get('filter') === 'unread' ? 'unread' : 'active',
|
||
query: '',
|
||
folder: '',
|
||
offset: 0,
|
||
pageSize: 100,
|
||
totalCount: null,
|
||
hasMore: false,
|
||
loadingMore: false,
|
||
searchTimer: null,
|
||
sagSearchTimer: null,
|
||
vendorSuggestion: null,
|
||
domainCustomerSuggestion: null,
|
||
senderIdentity: null,
|
||
senderDraft: null,
|
||
senderDraftRequest: 0,
|
||
senderSearchTimer: null,
|
||
supplierCaseCustomer: null,
|
||
supplierCustomerTimer: null,
|
||
purchaseMailKind: null,
|
||
selectedPurchase: null,
|
||
purchaseSearchTimer: null,
|
||
queueCounts: null,
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '')
|
||
.replace(/&/g, '&')
|
||
.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 = state.queueCounts || {
|
||
active: state.filter === 'active' ? state.emails.length : 0,
|
||
unread: state.filter === 'unread' ? state.emails.length : 0,
|
||
awaiting_user_action: state.filter === 'awaiting_user_action' ? state.emails.length : 0,
|
||
processed: state.filter === 'processed' ? state.emails.length : 0,
|
||
all: state.filter === 'all' ? state.emails.length : 0,
|
||
};
|
||
|
||
host.innerHTML = FILTERS.map((f) => {
|
||
const active = state.filter === f.key ? 'active' : '';
|
||
return `<button class="emails-v2-filter ${active}" data-filter="${f.key}">${escapeHtml(f.label)} (${counts[f.key] || 0})</button>`;
|
||
}).join('');
|
||
|
||
host.querySelectorAll('[data-filter]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.filter = btn.getAttribute('data-filter') || 'active';
|
||
state.selectedEmailId = null;
|
||
state.selectedEmail = null;
|
||
renderDetail(null);
|
||
loadEmails();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderList() {
|
||
const host = document.getElementById('v2List');
|
||
if (!host) return;
|
||
|
||
if (!state.emails.length) {
|
||
host.innerHTML = '<div class="emails-v2-empty">Ingen emails matcher dit filter</div>';
|
||
return;
|
||
}
|
||
|
||
const rows = state.emails.map((email) => {
|
||
const active = Number(email.id) === Number(state.selectedEmailId) ? 'active' : '';
|
||
const unread = email.is_read ? '' : 'unread';
|
||
const sender = email.sender_name || email.sender_email || 'Ukendt';
|
||
const preview = (email.body_text || email.subject || '').slice(0, 90).replace(/\s+/g, ' ').trim();
|
||
return `
|
||
<article class="emails-v2-item ${active} ${unread}" data-email-id="${Number(email.id)}">
|
||
<div class="head">
|
||
<div class="sender">${escapeHtml(sender)}</div>
|
||
<div class="time">${escapeHtml(formatDate(email.received_date))}</div>
|
||
</div>
|
||
<div class="subject">${escapeHtml(email.subject || '(Ingen emne)')}</div>
|
||
<div class="meta">
|
||
<span>Status: ${escapeHtml(email.status || '-')}</span>
|
||
<span>Type: ${escapeHtml(email.classification || 'general')}</span>
|
||
${email.linked_case_id ? `<span>SAG #${Number(email.linked_case_id)}</span>` : ''}
|
||
</div>
|
||
${preview ? `<div class="meta">${escapeHtml(preview)}</div>` : ''}
|
||
</article>
|
||
`;
|
||
}).join('');
|
||
const more = state.hasMore
|
||
? `<div class="p-3 text-center"><button id="v2LoadMore" class="btn btn-sm btn-outline-primary" ${state.loadingMore ? 'disabled' : ''}>${state.loadingMore ? 'Indlæser…' : 'Indlæs flere'}</button></div>`
|
||
: '';
|
||
host.innerHTML = rows + more;
|
||
|
||
host.querySelectorAll('[data-email-id]').forEach((node) => {
|
||
node.addEventListener('click', () => {
|
||
selectEmail(Number(node.getAttribute('data-email-id')));
|
||
});
|
||
});
|
||
document.getElementById('v2LoadMore')?.addEventListener('click', () => loadEmails({append: true}));
|
||
}
|
||
|
||
function emailListUrl(offset = 0) {
|
||
let url = `/api/v1/emails?limit=${state.pageSize}&offset=${offset}`;
|
||
if (state.filter === 'active') {
|
||
url += '&status=new';
|
||
} else if (state.filter === 'unread') {
|
||
url += '&unread=true';
|
||
} else if (state.filter === 'awaiting_user_action') {
|
||
url += '&status=awaiting_user_action';
|
||
} else if (state.filter === 'processed') {
|
||
url += '&status=processed';
|
||
}
|
||
if (state.query) url += `&q=${encodeURIComponent(state.query)}`;
|
||
if (state.folder) url += `&folder=${encodeURIComponent(state.folder)}`;
|
||
return url;
|
||
}
|
||
|
||
async function loadEmails({append = false} = {}) {
|
||
if (append && (state.loadingMore || !state.hasMore)) return;
|
||
const offset = append ? state.offset : 0;
|
||
state.loadingMore = append;
|
||
setListStatus(append ? 'Indlæser flere emails...' : 'Indlæser emails...');
|
||
const host = document.getElementById('v2List');
|
||
if (host && !append) {
|
||
host.innerHTML = '<div class="emails-v2-empty"><div class="spinner-border spinner-border-sm"></div> Henter...</div>';
|
||
}
|
||
|
||
try {
|
||
const [rows, stats, count] = await Promise.all([
|
||
apiFetch(emailListUrl(offset)),
|
||
apiFetch('/api/v1/emails/queue-counts').catch(() => null),
|
||
apiFetch(emailListUrl(0).replace('/emails?', '/emails/count?')).catch(() => null),
|
||
]);
|
||
const emails = Array.isArray(rows) ? rows : [];
|
||
|
||
if (stats) state.queueCounts = {
|
||
active: Number(stats.active || 0),
|
||
unread: Number(stats.unread || 0),
|
||
awaiting_user_action: Number(stats.awaiting_user_action || 0),
|
||
processed: Number(stats.processed || 0),
|
||
all: Number(stats.all || 0),
|
||
};
|
||
|
||
state.totalCount = Number.isFinite(Number(count?.count)) ? Number(count.count) : null;
|
||
state.emails = append ? [...state.emails, ...emails] : emails;
|
||
state.offset = state.emails.length;
|
||
state.hasMore = state.totalCount === null
|
||
? emails.length === state.pageSize
|
||
: state.offset < state.totalCount;
|
||
renderFilters();
|
||
renderList();
|
||
const totalLabel = state.totalCount === null ? `${state.emails.length}` : `${state.emails.length} af ${state.totalCount}`;
|
||
setListStatus(`Viser ${totalLabel} emails`);
|
||
} catch (error) {
|
||
if (!append) state.emails = [];
|
||
renderFilters();
|
||
renderList();
|
||
setListStatus(`Fejl: ${error.message}`);
|
||
} finally {
|
||
const wasLoadingMore = state.loadingMore;
|
||
state.loadingMore = false;
|
||
if (wasLoadingMore) renderList();
|
||
}
|
||
}
|
||
|
||
function syncSelectedEmailInList(email) {
|
||
const index = state.emails.findIndex((row) => Number(row.id) === Number(email.id));
|
||
if (index < 0) return;
|
||
state.emails[index] = {...state.emails[index], ...email};
|
||
renderList();
|
||
}
|
||
|
||
async function selectEmail(emailId) {
|
||
if (!emailId) return;
|
||
state.selectedEmailId = Number(emailId);
|
||
renderList();
|
||
setDetailStatus('Indlæser email...');
|
||
|
||
try {
|
||
const email = await apiFetch(`/api/v1/emails/${emailId}`);
|
||
if (state.selectedEmailId !== Number(emailId)) return;
|
||
state.selectedEmail = email;
|
||
syncSelectedEmailInList(email);
|
||
renderDetail(email);
|
||
setDetailStatus(`Email #${emailId} indlæst`);
|
||
} catch (error) {
|
||
setDetailStatus(`Fejl: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function patchReadState(isRead) {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/read-state`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ is_read: Boolean(isRead) }),
|
||
});
|
||
await selectEmail(state.selectedEmailId);
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke opdatere læsestatus: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function archiveCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
await apiFetch('/api/v1/emails/bulk/archive', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify([state.selectedEmailId]),
|
||
});
|
||
state.selectedEmailId = null;
|
||
state.selectedEmail = null;
|
||
renderDetail(null);
|
||
await loadEmails();
|
||
setDetailStatus('Email arkiveret');
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke arkivere email: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function markProcessedCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/mark-processed`, {
|
||
method: 'POST',
|
||
});
|
||
await selectEmail(state.selectedEmailId);
|
||
setDetailStatus('Email markeret som behandlet');
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke markere som behandlet: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function reprocessCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
setDetailStatus('Genbehandler email...');
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/reprocess`, {
|
||
method: 'POST',
|
||
});
|
||
await selectEmail(state.selectedEmailId);
|
||
setDetailStatus('Email genbehandlet');
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke genbehandle email: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function executeWorkflowsCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
setDetailStatus('Kører workflows...');
|
||
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/execute-workflows`, {
|
||
method: 'POST',
|
||
});
|
||
await selectEmail(state.selectedEmailId);
|
||
const executed = Number(result?.workflows_executed || result?.executed || 0);
|
||
setDetailStatus(`Workflows kørt (${executed})`);
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke køre workflows: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function loadWorkflowPreviewCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
const emailId = state.selectedEmailId;
|
||
try {
|
||
const preview = await apiFetch(`/api/v1/emails/${emailId}/workflow-preview`);
|
||
if (state.selectedEmailId !== emailId) return;
|
||
state.workflowPreview = preview || null;
|
||
if (preview?.identity) {
|
||
state.domainCustomerSuggestion = preview.identity.customer || {
|
||
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
|
||
suggestion: null,
|
||
};
|
||
state.vendorSuggestion = preview.identity.vendor?.suggestion || {};
|
||
renderDomainCustomerSuggestion();
|
||
renderVendorSuggestion();
|
||
}
|
||
renderWorkflowPreview();
|
||
} catch (error) {
|
||
if (state.selectedEmailId !== emailId) return;
|
||
state.workflowPreview = null;
|
||
renderWorkflowPreview(`Kunne ikke hente workflow-match: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function autoRunWorkflowsCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
setDetailStatus('Kører autokør...');
|
||
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/auto-run-workflows`, {
|
||
method: 'POST',
|
||
});
|
||
await selectEmail(state.selectedEmailId);
|
||
const executed = Number(result?.workflows_executed || result?.executed || 0);
|
||
setDetailStatus(`Autokør fuldført (${executed})`);
|
||
} catch (error) {
|
||
setDetailStatus(`Autokør fejlede: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
const CLASSIFICATION_LABELS = {
|
||
invoice: 'Leverandørfaktura',
|
||
freight_note: 'Fragtbrev',
|
||
order_confirmation: 'Ordrebekræftelse',
|
||
time_confirmation: 'Tidsbekræftelse',
|
||
case_notification: 'Sagsnotifikation',
|
||
customer_email: 'Kundehenvendelse',
|
||
bankruptcy: 'Konkursmeddelelse',
|
||
general: 'Almindelig email',
|
||
spam: 'Spam',
|
||
newsletter: 'Nyhedsbrev',
|
||
unknown: 'Ukendt type',
|
||
};
|
||
|
||
const WORKFLOW_ACTION_LABELS = {
|
||
create_ticket: 'Opretter en sag/opgave',
|
||
link_email_to_ticket: 'Knytter emailen til en eksisterende sag',
|
||
route_helpdesk_sag: 'Finder eller opretter den relevante sag',
|
||
create_time_entry: 'Opretter en tidsregistrering',
|
||
link_to_vendor: 'Knytter emailen til leverandøren',
|
||
link_to_customer: 'Knytter emailen til kunden',
|
||
extract_invoice_data: 'Sender PDF-bilaget til Leverandørfakturaer → Mangler behandling',
|
||
extract_tracking_number: 'Udtrækker trackingnummer',
|
||
regex_extract_and_link: 'Finder reference og opretter tilknytning',
|
||
process_also_cloud_billing: 'Importerer ALSO-fakturafilen og opretter ordrekladder',
|
||
send_slack_notification: 'Sender en Slack-notifikation',
|
||
send_email_notification: 'Sender en emailnotifikation',
|
||
mark_as_processed: 'Markerer emailen som behandlet',
|
||
flag_for_review: 'Markerer emailen til manuel kontrol',
|
||
};
|
||
|
||
async function openSenderCreate(useAi = false) {
|
||
const emailId = state.selectedEmailId;
|
||
const host = document.getElementById('v2CreateSenderCard');
|
||
if (!host) return;
|
||
const requestId = ++state.senderDraftRequest;
|
||
host.hidden = false;
|
||
host.innerHTML = '<h6>Virksomhed og kontakt</h6><div class="small" role="status">' + (useAi ? 'AI læser afsender og signatur…' : 'Henter forslag fra mailen…') + '</div>';
|
||
host.scrollIntoView({block: 'nearest', behavior: 'smooth'});
|
||
try {
|
||
const draft = await apiFetch(`/api/v1/emails/${emailId}/sender-draft?use_ai=${useAi}`);
|
||
if (state.selectedEmailId !== emailId || state.senderDraftRequest !== requestId) return;
|
||
state.senderDraft = draft;
|
||
renderSenderCreateForm();
|
||
} catch (error) {
|
||
if (state.selectedEmailId !== emailId || state.senderDraftRequest !== requestId) return;
|
||
host.innerHTML = `<div class="small text-danger">Forslag kunne ikke hentes: ${escapeHtml(error.message)}</div><button id="v2SenderDraftRetry" class="btn btn-sm btn-outline-primary mt-2">Prøv igen</button>`;
|
||
document.getElementById('v2SenderDraftRetry')?.addEventListener('click', () => openSenderCreate(false));
|
||
}
|
||
}
|
||
|
||
function renderSenderCreateForm() {
|
||
const draft = state.senderDraft;
|
||
const host = document.getElementById('v2CreateSenderCard');
|
||
if (!host || !draft) return;
|
||
const email = state.selectedEmail || {};
|
||
const kind = email.supplier_id ? 'vendor' : document.getElementById('v2SenderKind')?.value || 'customer';
|
||
const fields = (group, definitions) => definitions.map(([key, label, type]) => `
|
||
<label class="small mt-2" for="v2Create-${group}-${key}">${label}</label>
|
||
<input id="v2Create-${group}-${key}" data-create-group="${group}" data-create-field="${key}" type="${type || 'text'}" class="form-control form-control-sm" value="${escapeHtml(draft[group]?.[key] || '')}">`).join('');
|
||
host.innerHTML = `<form id="v2SenderCreateForm">
|
||
<h6>Virksomhed og kontakt</h6>
|
||
<div class="small text-muted mb-2">Forslag fra afsender og nyeste signatur. Kontrollér og ret før du gemmer.</div>
|
||
${draft.ai_status === 'failed' ? '<div class="small text-warning">AI kunne ikke hente alle oplysninger. Kontrollér forslagene.</div>' : ''}
|
||
${draft.ai_status === 'not_requested' ? '<button id="v2SenderDraftAI" class="btn btn-sm btn-outline-primary mb-3" type="button">Find flere oplysninger med AI</button>' : ''}
|
||
<label class="small" for="v2CreateKind">Virksomhedstype</label>
|
||
<select id="v2CreateKind" class="form-select form-select-sm"><option value="customer" ${kind === 'customer' ? 'selected' : ''}>Kunde</option><option value="vendor" ${kind === 'vendor' ? 'selected' : ''}>Leverandør</option></select>
|
||
<label class="small mt-2" for="v2CreateCompanyChoice">Virksomhed</label>
|
||
<select id="v2CreateCompanyChoice" class="form-select form-select-sm"></select>
|
||
<div id="v2CreateCompanyFields">${fields('company', [['name','Virksomhedsnavn'], ['cvr_number','CVR'], ['phone','Virksomhedens telefon'], ['email','Virksomhedens mail','email'], ['address','Adresse'], ['postal_code','Postnummer'], ['city','By'], ['website','Hjemmeside']])}</div>
|
||
<hr><label class="small" for="v2CreateContactChoice">Kontaktperson</label>
|
||
<select id="v2CreateContactChoice" class="form-select form-select-sm">
|
||
<option value="">Opret ny kontakt</option>
|
||
${(draft.contacts || []).map(row => `<option value="${Number(row.id)}">Brug ${escapeHtml(row.first_name)} ${escapeHtml(row.last_name)} (${escapeHtml(row.email)})</option>`).join('')}
|
||
</select>
|
||
<div id="v2CreateContactFields">${fields('contact', [['first_name','Fornavn'], ['last_name','Efternavn'], ['email','Kontaktens mail','email'], ['phone','Direkte telefon'], ['mobile','Mobil'], ['title','Stilling'], ['department','Afdeling']])}</div>
|
||
<div class="small text-muted mt-2">Eksisterende oplysninger overskrives ikke. Der gemmes ingen domæneregel.</div>
|
||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||
<button id="v2SenderCreateSave" class="btn btn-primary btn-sm" type="submit">Gem virksomhed og kontakt</button>
|
||
<button id="v2SenderCreateCancel" class="btn btn-outline-secondary btn-sm" type="button">Annuller</button>
|
||
</div>
|
||
<div id="v2SenderCreateStatus" class="small mt-2" role="status"></div>
|
||
</form>`;
|
||
const updateCompanies = () => {
|
||
const kind = document.getElementById('v2CreateKind').value;
|
||
const rows = (draft.companies || []).filter(row => row.kind === kind);
|
||
const linkedId = kind === 'customer' ? email.customer_id : email.supplier_id;
|
||
const linkedName = kind === 'customer' ? email.customer_name : email.supplier_name;
|
||
if (linkedId && !rows.some(row => Number(row.id) === Number(linkedId))) rows.push({id: linkedId, name: linkedName || 'Allerede tilknyttet'});
|
||
const select = document.getElementById('v2CreateCompanyChoice');
|
||
select.innerHTML = '<option value="">Opret ny virksomhed</option>' + rows.map(row => `<option value="${Number(row.id)}">Brug eksisterende: ${escapeHtml(row.name)}</option>`).join('');
|
||
if (linkedId) select.value = String(linkedId);
|
||
else if (rows.length === 1) select.value = String(rows[0].id);
|
||
updateFields();
|
||
};
|
||
const updateFields = () => {
|
||
document.getElementById('v2CreateCompanyFields').hidden = Boolean(document.getElementById('v2CreateCompanyChoice').value);
|
||
document.getElementById('v2CreateContactFields').hidden = Boolean(document.getElementById('v2CreateContactChoice').value);
|
||
};
|
||
if ((draft.contacts || []).length === 1) document.getElementById('v2CreateContactChoice').value = String(draft.contacts[0].id);
|
||
document.getElementById('v2CreateKind').addEventListener('change', updateCompanies);
|
||
document.getElementById('v2CreateCompanyChoice').addEventListener('change', updateFields);
|
||
document.getElementById('v2CreateContactChoice').addEventListener('change', updateFields);
|
||
document.getElementById('v2SenderCreateCancel').addEventListener('click', () => {host.hidden = true;});
|
||
document.getElementById('v2SenderDraftAI')?.addEventListener('click', () => openSenderCreate(true));
|
||
document.getElementById('v2SenderCreateForm').addEventListener('submit', submitSenderCreate);
|
||
updateCompanies();
|
||
}
|
||
|
||
async function submitSenderCreate(event) {
|
||
event.preventDefault();
|
||
const emailId = state.selectedEmailId;
|
||
const button = document.getElementById('v2SenderCreateSave');
|
||
if (button.disabled) return;
|
||
const payload = {kind: document.getElementById('v2CreateKind').value, company: {}, contact: {}};
|
||
document.querySelectorAll('[data-create-group]').forEach(input => {
|
||
payload[input.dataset.createGroup][input.dataset.createField] = input.value.trim() || null;
|
||
});
|
||
payload.company.existing_id = Number(document.getElementById('v2CreateCompanyChoice').value) || null;
|
||
payload.contact.existing_id = Number(document.getElementById('v2CreateContactChoice').value) || null;
|
||
// Blank names are valid for reused records, but new records need explicit names.
|
||
for (const [group, keys] of [['company', ['name']], ['contact', ['first_name','last_name','email']]])
|
||
keys.forEach(key => {payload[group][key] ||= '';});
|
||
const status = document.getElementById('v2SenderCreateStatus');
|
||
button.disabled = true;
|
||
status.textContent = 'Gemmer virksomhed og kontakt…';
|
||
try {
|
||
const result = await apiFetch(`/api/v1/emails/${emailId}/create-sender`, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
|
||
});
|
||
if (state.selectedEmailId !== emailId) return;
|
||
await selectEmail(emailId);
|
||
setDetailStatus(result.reused_contact ? 'Virksomheden er tilknyttet den eksisterende kontakt' : 'Virksomhed og kontakt er gemt og tilknyttet mailen');
|
||
} catch (error) {
|
||
if (state.selectedEmailId !== emailId) return;
|
||
button.disabled = false;
|
||
status.textContent = `Kunne ikke gemme: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
function simplifySideSections(host) {
|
||
// All sidebar sections are permanently expanded; headings are not controls.
|
||
[...host.querySelectorAll('details')].reverse().forEach(details => {
|
||
const summary = details.querySelector(':scope > summary');
|
||
if (!summary) return;
|
||
const section = document.createElement('section');
|
||
section.className = details.className;
|
||
const heading = document.createElement('h6');
|
||
heading.className = 'emails-v2-section-heading';
|
||
heading.textContent = summary.textContent.trim().replace(/^Vis /i, '');
|
||
const content = document.createElement('div');
|
||
content.className = 'emails-v2-section-content';
|
||
[...details.childNodes].filter(node => node !== summary).forEach(node => content.appendChild(node));
|
||
section.append(heading, content);
|
||
details.replaceWith(section);
|
||
});
|
||
}
|
||
|
||
function renderIdentityAssessment() {
|
||
const host = document.getElementById('v2SenderIdentity');
|
||
if (!host) return;
|
||
const email = state.selectedEmail || {};
|
||
const identity = state.senderIdentity;
|
||
const labels = {contact_email: 'Mailadresse matcher kontakt', exact_email: 'Mailadresse matcher virksomheden',
|
||
saved_domain: 'Gemt domænetilknytning', exact_domain: 'Domænet matcher virksomheden'};
|
||
const candidates = identity?.candidates || [];
|
||
const current = [email.customer_id ? `Kunde: ${email.customer_name || '#' + email.customer_id}` : '',
|
||
email.supplier_id ? `Leverandør: ${email.supplier_name || '#' + email.supplier_id}` : ''].filter(Boolean);
|
||
host.innerHTML = `
|
||
<h6>Afsender</h6>
|
||
<div class="fw-semibold">${escapeHtml(email.sender_name || identity?.address || email.sender_email)}</div>
|
||
<div class="small text-break mb-2">${escapeHtml(identity?.address || email.sender_email)}</div>
|
||
${current.length ? `<div class="small text-success mb-2">${current.map(escapeHtml).join('<br>')}</div>` : ''}
|
||
${!identity ? '<div class="small text-muted">Finder afsender…</div>' : identity.error
|
||
? '<div class="small text-danger">Afsender kunne ikke genkendes. Søg manuelt nedenfor.</div>'
|
||
: !current.length && !candidates.length ? '<div class="small text-muted">Ingen kendt afsender. Søg efter en eksisterende kunde eller leverandør.</div>' : ''}
|
||
${!current.length && identity?.ambiguous ? '<div class="small text-warning mb-2">Flere mulige tilknytninger — vælg den relevante.</div>' : ''}
|
||
${!current.length ? candidates.map(row => `
|
||
<div class="border rounded p-2 mt-2">
|
||
<div class="fw-semibold small">${escapeHtml(row.name)}</div>
|
||
<div class="small text-muted">${row.kind === 'vendor' ? 'Leverandør' : 'Kunde'} · ${escapeHtml(labels[row.source] || row.source)}</div>
|
||
${row.contact_name ? `<div class="small">Kontakt: ${escapeHtml(row.contact_name)}</div>` : ''}
|
||
<button class="btn btn-sm btn-outline-primary mt-1" data-sender-kind="${escapeHtml(row.kind)}" data-sender-id="${Number(row.id)}">Tilknyt</button>
|
||
</div>`).join('') : ''}
|
||
<div class="mt-3">
|
||
<div class="small fw-semibold">${current.length ? 'Skift tilknytning' : 'Søg kunde eller leverandør'}</div>
|
||
<label class="small mt-2" for="v2SenderKind">Søg blandt</label>
|
||
<select id="v2SenderKind" class="form-select form-select-sm"><option value="customer">Kunder</option><option value="vendor">Leverandører</option></select>
|
||
<input id="v2SenderSearch" class="form-control form-control-sm mt-2" placeholder="Navn, mail, domæne eller CVR" aria-label="Søg afsender">
|
||
<div id="v2SenderResults" class="mt-2"></div>
|
||
</div>
|
||
<button id="v2OpenSenderCreate" class="btn btn-sm btn-primary mt-3">Opret kunde / leverandør + kontakt</button>
|
||
<div id="v2SenderError" class="small text-danger mt-2" role="status"></div>`;
|
||
bindIdentityDecisionActions();
|
||
}
|
||
|
||
function bindIdentityDecisionActions() {
|
||
const host = document.getElementById('v2SenderIdentity');
|
||
host?.querySelectorAll('[data-sender-id]').forEach(button => {
|
||
button.addEventListener('click', () => linkSenderIdentity(button.dataset.senderKind, Number(button.dataset.senderId), button));
|
||
});
|
||
document.getElementById('v2OpenSenderCreate')?.addEventListener('click', () => openSenderCreate(false));
|
||
const search = document.getElementById('v2SenderSearch');
|
||
const scheduleSearch = () => {
|
||
clearTimeout(state.senderSearchTimer);
|
||
state.senderSearchTimer = setTimeout(searchSenderIdentity, 220);
|
||
};
|
||
search?.addEventListener('input', scheduleSearch);
|
||
document.getElementById('v2SenderKind')?.addEventListener('change', scheduleSearch);
|
||
}
|
||
|
||
async function loadSenderIdentity() {
|
||
const emailId = state.selectedEmailId;
|
||
state.senderIdentity = null;
|
||
renderIdentityAssessment();
|
||
try {
|
||
const result = await apiFetch(`/api/v1/emails/${emailId}/sender-identity`);
|
||
if (state.selectedEmailId !== emailId) return;
|
||
state.senderIdentity = result;
|
||
} catch (error) {
|
||
if (state.selectedEmailId !== emailId) return;
|
||
state.senderIdentity = {error: true};
|
||
}
|
||
renderIdentityAssessment();
|
||
const email = state.selectedEmail || {};
|
||
const candidates = state.senderIdentity?.candidates || [];
|
||
if (!email.customer_id && !email.supplier_id && candidates.length === 1
|
||
&& candidates[0].source === 'contact_email') {
|
||
// Only an exact, unambiguous existing contact relation is automatic.
|
||
// Domain matches and conflicting company relations stay suggestions.
|
||
await linkSenderIdentity(candidates[0].kind, Number(candidates[0].id), {disabled: false});
|
||
}
|
||
}
|
||
|
||
async function searchSenderIdentity() {
|
||
const emailId = state.selectedEmailId;
|
||
const input = document.getElementById('v2SenderSearch');
|
||
const kindInput = document.getElementById('v2SenderKind');
|
||
const query = input?.value.trim() || '';
|
||
const kind = kindInput?.value;
|
||
const host = document.getElementById('v2SenderResults');
|
||
if (!host) return;
|
||
host.textContent = query.length < 2 ? 'Skriv mindst to tegn.' : 'Søger…';
|
||
if (query.length < 2) return;
|
||
try {
|
||
const url = kind === 'vendor' ? `/api/v1/vendors?search=${encodeURIComponent(query)}`
|
||
: `/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`;
|
||
const rows = await apiFetch(url);
|
||
if (state.selectedEmailId !== emailId || document.getElementById('v2SenderSearch') !== input
|
||
|| input.value.trim() !== query || kindInput.value !== kind) return;
|
||
host.innerHTML = rows.length ? rows.map(row => `
|
||
<button class="btn btn-sm btn-outline-secondary w-100 text-start mb-1" data-sender-kind="${kind}" data-sender-id="${Number(row.id)}">${escapeHtml(row.name)}${row.cvr_number ? ` · ${escapeHtml(row.cvr_number)}` : ''}</button>`).join('')
|
||
: '<div class="small text-muted">Ingen fundet. Prøv et andet navn eller CVR.</div>';
|
||
host.querySelectorAll('[data-sender-id]').forEach(button => button.addEventListener('click',
|
||
() => linkSenderIdentity(kind, Number(button.dataset.senderId), button)));
|
||
} catch (error) {
|
||
if (document.getElementById('v2SenderSearch') === input && input.value.trim() === query && kindInput.value === kind)
|
||
host.textContent = 'Søgningen fejlede. Prøv igen.';
|
||
}
|
||
}
|
||
|
||
async function linkSenderIdentity(kind, id, button) {
|
||
const emailId = state.selectedEmailId;
|
||
button.disabled = true;
|
||
try {
|
||
await apiFetch(`/api/v1/emails/${emailId}/link`, {
|
||
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({[kind === 'vendor' ? 'supplier_id' : 'customer_id']: id, remember_domain: false}),
|
||
});
|
||
if (state.selectedEmailId === emailId) await selectEmail(emailId);
|
||
} catch (error) {
|
||
if (state.selectedEmailId !== emailId) return;
|
||
button.disabled = false;
|
||
const host = document.getElementById('v2SenderError');
|
||
if (host) host.textContent = `Kunne ikke tilknytte: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
function renderClassificationDecision(errorMessage) {
|
||
const host = document.getElementById('v2DecisionCard');
|
||
if (!host) return;
|
||
if (errorMessage) {
|
||
host.innerHTML = `
|
||
<div class="fw-semibold text-danger">Vurdering kunne ikke indlæses</div>
|
||
<div class="small text-muted mt-1">${escapeHtml(errorMessage)}</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const preview = state.workflowPreview;
|
||
if (!preview) {
|
||
host.innerHTML = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Systemet vurderer mailen…</div>';
|
||
return;
|
||
}
|
||
|
||
const meta = preview.email || {};
|
||
const classification = String(meta.classification || 'unknown').toLowerCase();
|
||
const typeLabel = CLASSIFICATION_LABELS[classification] || classification;
|
||
const rawConfidence = Number(meta.confidence_score || 0);
|
||
const confidencePercent = Math.round(Math.max(0, Math.min(1, rawConfidence)) * 100);
|
||
const confidenceLabel = confidencePercent >= 80
|
||
? 'Høj sikkerhed'
|
||
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed – kontrollér manuelt';
|
||
|
||
const email = state.selectedEmail || {};
|
||
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
|
||
|
||
const effects = [];
|
||
if (isApprovedSupplierInvoice) {
|
||
effects.push(
|
||
'Beholder tilknytningen til den valgte leverandør',
|
||
'Henter PDF-bilaget og sender det til Leverandørfakturaer',
|
||
'Aflæser fakturanummer, beløb og varelinjer',
|
||
'Opdaterer Internetforbindelser automatisk, når fakturaen er fra GlobalConnect',
|
||
'Markerer emailen som behandlet'
|
||
);
|
||
} else {
|
||
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
|
||
if (row.effect) effects.push(row.effect);
|
||
});
|
||
(preview.matching_workflows || []).forEach((workflow) => {
|
||
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
|
||
actions.forEach((action) => effects.push(
|
||
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
|
||
));
|
||
});
|
||
}
|
||
const uniqueEffects = [...new Set(effects)];
|
||
const hasActions = uniqueEffects.length > 0;
|
||
const automaticExecution = Boolean(preview.automatic_execution);
|
||
|
||
host.innerHTML = `
|
||
<div class="small text-uppercase fw-bold text-muted mb-1">Mailtype</div>
|
||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
|
||
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
|
||
</div>
|
||
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Behandlingen vælges under Handling.</div>
|
||
<details class="mt-2">
|
||
<summary class="small">Vis vurdering og handlinger</summary>
|
||
<div class="mt-3">
|
||
|
||
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
|
||
${hasActions ? `
|
||
<ol class="email-ai-actions mb-2">
|
||
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
|
||
</ol>
|
||
${automaticExecution ? `
|
||
<div class="alert alert-danger py-2 small mb-0">
|
||
<i class="bi bi-lightning-charge-fill me-1"></i>
|
||
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
|
||
</div>` : ''}
|
||
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
|
||
</div>
|
||
</details>
|
||
`;
|
||
simplifySideSections(host);
|
||
}
|
||
|
||
async function approveSuggestedActions() {
|
||
const button = document.getElementById('v2ApproveActions');
|
||
if (!button || !state.selectedEmailId) return;
|
||
button.disabled = true;
|
||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
|
||
try {
|
||
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
|
||
if (classification === 'invoice' && state.selectedEmail?.supplier_id) {
|
||
await runQuickAction('create_supplier_invoice', button);
|
||
} else {
|
||
await executeWorkflowsCurrent();
|
||
}
|
||
} finally {
|
||
if (document.body.contains(button)) {
|
||
button.disabled = false;
|
||
button.innerHTML = '<i class="bi bi-check-circle me-2"></i>Godkend og udfør';
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderWorkflowPreview(errorMessage) {
|
||
const host = document.getElementById('v2WorkflowPreview');
|
||
if (!host) return;
|
||
|
||
if (errorMessage) {
|
||
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
|
||
renderClassificationDecision(errorMessage);
|
||
return;
|
||
}
|
||
|
||
const preview = state.workflowPreview;
|
||
if (!preview) {
|
||
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
|
||
renderClassificationDecision();
|
||
return;
|
||
}
|
||
|
||
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
|
||
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
|
||
const emailMeta = preview.email || {};
|
||
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
|
||
&& Boolean(state.selectedEmail?.supplier_id);
|
||
|
||
const systemHtml = system.map((row) => {
|
||
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
|
||
return `
|
||
<div class="emails-v2-kv">
|
||
<div class="k">${escapeHtml(row.name || row.code || 'System')}</div>
|
||
<div class="v">${badge} <span class="small text-muted">${escapeHtml(row.reason || '')}</span></div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const matchingHtml = usesSupplierApprovalFlow
|
||
? `<div class="emails-v2-kv">
|
||
<div class="k">Godkendelsesflow</div>
|
||
<div class="v">
|
||
<span class="badge bg-success">Leverandørfaktura</span>
|
||
<span class="small text-muted">PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet</span>
|
||
</div>
|
||
</div>`
|
||
: matching.length
|
||
? matching.map((wf) => `
|
||
<div class="emails-v2-kv">
|
||
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
|
||
<div class="v">
|
||
<span class="badge bg-success">Matcher</span>
|
||
<span class="small text-muted">prio ${escapeHtml(String(wf.priority ?? '-'))} • min conf ${escapeHtml(String(wf.confidence_threshold ?? '-'))} • steps ${escapeHtml(String(wf.steps_total ?? 0))}</span>
|
||
</div>
|
||
</div>
|
||
`).join('')
|
||
: '<div class="small text-muted">Ingen bruger-workflows matcher aktuelt.</div>';
|
||
|
||
host.innerHTML = `
|
||
<div class="emails-v2-kv"><div class="k">Klassifikation</div><div class="v">${escapeHtml(emailMeta.classification || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Confidence</div><div class="v">${escapeHtml(String(emailMeta.confidence_score ?? 0))}</div></div>
|
||
<div class="mt-2">
|
||
<div class="small fw-semibold mb-1">Systemflows</div>
|
||
${systemHtml || '<div class="small text-muted">Ingen systemflows</div>'}
|
||
</div>
|
||
<div class="mt-2">
|
||
<div class="small fw-semibold mb-1">Workflow match</div>
|
||
${matchingHtml}
|
||
</div>
|
||
`;
|
||
renderClassificationDecision();
|
||
|
||
const autoRunBtn = document.getElementById('v2AutoRun');
|
||
if (autoRunBtn) {
|
||
const enabled = Boolean(preview.auto_run_enabled);
|
||
autoRunBtn.disabled = !enabled;
|
||
autoRunBtn.title = enabled
|
||
? 'Autokør er aktiv'
|
||
: 'Aktiveres senere via EMAIL_WORKFLOW_AUTORUN_ENABLED=true';
|
||
}
|
||
}
|
||
|
||
async function autoParseAndLinkCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
setDetailStatus('Auto parser email til tråd/sag...');
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/execute-workflows`, {
|
||
method: 'POST',
|
||
});
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/reprocess`, {
|
||
method: 'POST',
|
||
});
|
||
await selectEmail(state.selectedEmailId);
|
||
setDetailStatus('Auto parse gennemført');
|
||
} catch (error) {
|
||
setDetailStatus(`Auto parse fejlede: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function openPurchaseMail(kind) {
|
||
state.purchaseMailKind = kind;
|
||
state.selectedPurchase = null;
|
||
const form = document.getElementById('v2PurchaseMailForm');
|
||
form.hidden = false;
|
||
document.getElementById('v2PurchaseMailTitle').textContent = kind === 'freight_note' ? 'Levering / tracking' : 'Ordrebekræftelse';
|
||
document.getElementById('v2PurchaseTrackingField').hidden = kind !== 'freight_note';
|
||
document.getElementById('v2PurchaseTracking').value = '';
|
||
document.getElementById('v2PurchaseSearch').value = '';
|
||
document.getElementById('v2PurchaseSelected').textContent = '';
|
||
document.getElementById('v2PurchaseStatus').textContent = '';
|
||
document.getElementById('v2PurchaseSubmit').disabled = true;
|
||
form.scrollIntoView({block: 'nearest', behavior: 'smooth'});
|
||
document.getElementById('v2PurchaseSearch').focus();
|
||
searchPurchaseOptions();
|
||
}
|
||
|
||
async function searchPurchaseOptions() {
|
||
const emailId = state.selectedEmailId;
|
||
const input = document.getElementById('v2PurchaseSearch');
|
||
const host = document.getElementById('v2PurchaseOptions');
|
||
if (!input || !host) return;
|
||
const query = input.value.trim();
|
||
host.textContent = 'Søger…';
|
||
try {
|
||
const rows = await apiFetch(`/api/v1/emails/purchase-options?q=${encodeURIComponent(query)}`);
|
||
if (state.selectedEmailId !== emailId || document.getElementById('v2PurchaseSearch') !== input || input.value.trim() !== query) return;
|
||
host.innerHTML = rows.length ? rows.map(row => `<button type="button" class="btn btn-outline-secondary btn-sm w-100 text-start mb-2" data-purchase-id="${Number(row.id)}">
|
||
<strong>${escapeHtml(row.description)}</strong><br>
|
||
<span class="small">${escapeHtml(row.customer_name || 'Ukendt kunde')} · Sag #${Number(row.sag_id)} · ${escapeHtml(row.case_title)}</span>
|
||
${row.external_ref ? `<br><span class="small">Reference: ${escapeHtml(row.external_ref)}</span>` : ''}</button>`).join('')
|
||
: '<div class="small text-muted">Ingen bestillinger fundet. Prøv varenavn, reference eller kunde.</div>';
|
||
host.querySelectorAll('[data-purchase-id]').forEach(button => button.addEventListener('click', () => {
|
||
const row = rows.find(item => Number(item.id) === Number(button.dataset.purchaseId));
|
||
state.selectedPurchase = row;
|
||
document.getElementById('v2PurchaseSelected').textContent = `Valgt: ${row.description} — ${row.customer_name || 'Ukendt kunde'} · Sag #${row.sag_id}`;
|
||
document.getElementById('v2PurchaseSubmit').disabled = false;
|
||
}));
|
||
} catch (error) {
|
||
if (document.getElementById('v2PurchaseSearch') === input) host.textContent = 'Bestillinger kunne ikke hentes. Prøv igen.';
|
||
}
|
||
}
|
||
|
||
async function submitPurchaseMail(event) {
|
||
event.preventDefault();
|
||
const emailId = state.selectedEmailId;
|
||
const button = document.getElementById('v2PurchaseSubmit');
|
||
const status = document.getElementById('v2PurchaseStatus');
|
||
if (!state.selectedPurchase || button.disabled) return;
|
||
const payload = {purchase_id: Number(state.selectedPurchase.id), kind: state.purchaseMailKind,
|
||
tracking_number: state.purchaseMailKind === 'freight_note' ? document.getElementById('v2PurchaseTracking').value.trim() || null : null};
|
||
button.disabled = true;
|
||
status.textContent = 'Knytter mailen til bestillingen…';
|
||
try {
|
||
const result = await apiFetch(`/api/v1/emails/${emailId}/purchase-link`, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload),
|
||
});
|
||
if (state.selectedEmailId !== emailId) return;
|
||
await selectEmail(emailId);
|
||
setDetailStatus(result.message);
|
||
} catch (error) {
|
||
if (state.selectedEmailId !== emailId) return;
|
||
status.textContent = error.message;
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function openCaseLinkPicker() {
|
||
const picker = document.getElementById('v2CaseLinkPicker');
|
||
const input = document.getElementById('v2SagSearch');
|
||
if (!picker || !input) return;
|
||
picker.scrollIntoView({block: 'nearest', behavior: 'smooth'});
|
||
input.focus();
|
||
}
|
||
|
||
function openSupplierCase() {
|
||
const form = document.getElementById('v2SupplierCaseForm');
|
||
if (!form) return;
|
||
form.hidden = false;
|
||
form.scrollIntoView({block: 'nearest', behavior: 'smooth'});
|
||
document.getElementById(state.supplierCaseCustomer ? 'v2SupplierCaseTitle' : 'v2SupplierCaseCustomer')?.focus();
|
||
}
|
||
|
||
async function searchSupplierCaseCustomer() {
|
||
const emailId = state.selectedEmailId;
|
||
const input = document.getElementById('v2SupplierCaseCustomer');
|
||
const host = document.getElementById('v2SupplierCaseCustomers');
|
||
const query = input?.value.trim() || '';
|
||
if (!host) return;
|
||
host.textContent = query.length < 2 ? 'Skriv mindst to tegn.' : 'Søger…';
|
||
if (query.length < 2) return;
|
||
try {
|
||
const rows = await apiFetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
|
||
if (state.selectedEmailId !== emailId || document.getElementById('v2SupplierCaseCustomer') !== input || input.value.trim() !== query) return;
|
||
host.innerHTML = rows.length ? rows.map(row => `<button type="button" class="btn btn-sm btn-outline-secondary w-100 text-start mb-1" data-customer-id="${Number(row.id)}">${escapeHtml(row.name)}${row.cvr_number ? ` · ${escapeHtml(row.cvr_number)}` : ''}</button>`).join('')
|
||
: '<div class="small text-muted">Ingen kunder fundet.</div>';
|
||
host.querySelectorAll('[data-customer-id]').forEach(button => button.addEventListener('click', () => {
|
||
const row = rows.find(item => Number(item.id) === Number(button.dataset.customerId));
|
||
state.supplierCaseCustomer = {id: Number(row.id), name: row.name};
|
||
input.value = row.name;
|
||
host.innerHTML = '';
|
||
document.getElementById('v2SupplierCaseSubmit').disabled = false;
|
||
}));
|
||
} catch (error) {
|
||
if (state.selectedEmailId === emailId && document.getElementById('v2SupplierCaseCustomer') === input)
|
||
host.textContent = 'Søgningen fejlede. Prøv igen.';
|
||
}
|
||
}
|
||
|
||
async function createSupplierCase(event) {
|
||
event.preventDefault();
|
||
const emailId = state.selectedEmailId;
|
||
const button = document.getElementById('v2SupplierCaseSubmit');
|
||
const status = document.getElementById('v2SupplierCaseStatus');
|
||
if (!emailId || !state.selectedEmail?.supplier_id || !state.supplierCaseCustomer || button?.disabled) return;
|
||
const title = document.getElementById('v2SupplierCaseTitle').value.trim();
|
||
if (!title) {
|
||
status.textContent = 'Skriv en titel til sagen.';
|
||
return;
|
||
}
|
||
const contact = state.senderIdentity?.candidates?.find(row => row.kind === 'vendor'
|
||
&& Number(row.id) === Number(state.selectedEmail.supplier_id) && row.source === 'contact_email');
|
||
button.disabled = true;
|
||
status.textContent = 'Opretter sag og tilknytter mail…';
|
||
try {
|
||
const result = await apiFetch(`/api/v1/emails/${emailId}/create-sag`, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({titel: title, case_type: 'leverandor',
|
||
customer_id: state.supplierCaseCustomer.id, contact_id: contact?.contact_id || null}),
|
||
});
|
||
if (state.selectedEmailId !== emailId) return;
|
||
await selectEmail(emailId);
|
||
setDetailStatus(`Leverandørsag #${Number(result?.sag?.id)} oprettet`);
|
||
} catch (error) {
|
||
if (state.selectedEmailId !== emailId) return;
|
||
status.textContent = `Sagen kunne ikke oprettes: ${error.message}`;
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function createCaseFromCurrent() {
|
||
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||
|
||
const caseTypeEl = document.getElementById('v2CaseType');
|
||
const titelEl = document.getElementById('v2CaseTitle');
|
||
const createButton = document.getElementById('v2CreateCase');
|
||
const payload = {
|
||
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
|
||
case_type: String(caseTypeEl?.value || 'support'),
|
||
};
|
||
|
||
if (state.selectedEmail.customer_id) {
|
||
payload.customer_id = Number(state.selectedEmail.customer_id);
|
||
}
|
||
|
||
try {
|
||
if (createButton) {
|
||
createButton.disabled = true;
|
||
createButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter…';
|
||
}
|
||
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
const sagId = Number(result?.sag?.id || result?.sag_id || 0);
|
||
setDetailStatus(sagId ? `SAG #${sagId} oprettet` : 'SAG oprettet');
|
||
await selectEmail(state.selectedEmailId);
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke oprette sag: ${error.message}`);
|
||
window.alert(`Sagen kunne ikke oprettes:\n${error.message}`);
|
||
} finally {
|
||
if (createButton && document.body.contains(createButton)) {
|
||
createButton.disabled = false;
|
||
createButton.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Opret';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function runQuickAction(action, button) {
|
||
if (!state.selectedEmailId || !state.selectedEmail || !action) return;
|
||
const originalHtml = button?.innerHTML || '';
|
||
try {
|
||
if (button) {
|
||
button.disabled = true;
|
||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Udfører…';
|
||
}
|
||
const title = String(document.getElementById('v2CaseTitle')?.value
|
||
|| state.selectedEmail.subject
|
||
|| '').trim();
|
||
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/quick-action`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action, titel: title }),
|
||
});
|
||
|
||
if (action === 'archive_entity_mail') {
|
||
state.selectedEmailId = null;
|
||
state.selectedEmail = null;
|
||
renderDetail(null);
|
||
await loadEmails();
|
||
} else {
|
||
await selectEmail(state.selectedEmailId);
|
||
}
|
||
setDetailStatus(result?.message || 'Handlingen er udført');
|
||
} catch (error) {
|
||
setDetailStatus(`Handlingen fejlede: ${error.message}`);
|
||
window.alert(`Handlingen kunne ikke udføres:\n${error.message}`);
|
||
} finally {
|
||
if (button && document.body.contains(button)) {
|
||
button.disabled = false;
|
||
button.innerHTML = originalHtml;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function linkSelectedSag(sagId) {
|
||
if (!state.selectedEmailId || !sagId) return;
|
||
try {
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link-sag`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sag_id: Number(sagId), relation_type: 'mail', mark_processed: true }),
|
||
});
|
||
setDetailStatus(`Email linket til SAG #${sagId}`);
|
||
await selectEmail(state.selectedEmailId);
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke linke sag: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function setSupplierStatus(message, type) {
|
||
const el = document.getElementById('v2SupplierStatus');
|
||
if (!el) return;
|
||
el.className = `emails-v2-inline-status ${type || ''}`.trim();
|
||
el.textContent = message || '';
|
||
}
|
||
|
||
function renderVendorSuggestion() {
|
||
const host = document.getElementById('v2VendorSuggestion');
|
||
if (!host) return;
|
||
|
||
const suggestion = state.vendorSuggestion;
|
||
if (!suggestion) {
|
||
host.innerHTML = '<div class="small text-muted">Ingen forslag endnu</div>';
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = `
|
||
<div class="emails-v2-kv"><div class="k">Navn</div><div class="v">${escapeHtml(suggestion.name || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(suggestion.cvr_number || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Adresse</div><div class="v">${escapeHtml(suggestion.address || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Telefon</div><div class="v">${escapeHtml(suggestion.phone || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Email</div><div class="v">${escapeHtml(suggestion.email || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Domæne</div><div class="v">${escapeHtml(suggestion.domain || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Match</div><div class="v">Vendor ID: ${escapeHtml(String(suggestion.vendor_id || '-'))} • Score: ${escapeHtml(String(suggestion.match_score || 0))}</div></div>
|
||
`;
|
||
}
|
||
|
||
async function extractVendorSuggestionCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
setSupplierStatus('Udtrækker leverandørforslag...');
|
||
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/extract-vendor-suggestion`, {
|
||
method: 'POST',
|
||
});
|
||
state.vendorSuggestion = suggestion || null;
|
||
renderVendorSuggestion();
|
||
renderClassificationDecision();
|
||
setSupplierStatus('Forslag opdateret', 'success');
|
||
} catch (error) {
|
||
state.vendorSuggestion = {};
|
||
renderClassificationDecision();
|
||
setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function setDomainStatus(message, type) {
|
||
const el = document.getElementById('v2DomainStatus');
|
||
if (!el) return;
|
||
el.className = `emails-v2-inline-status ${type || ''}`.trim();
|
||
el.textContent = message || '';
|
||
}
|
||
|
||
function renderDomainCustomerSuggestion() {
|
||
const host = document.getElementById('v2DomainSuggestion');
|
||
if (!host) return;
|
||
|
||
const data = state.domainCustomerSuggestion;
|
||
if (!data) {
|
||
host.innerHTML = '<div class="small text-muted">Ingen domæneforslag endnu</div>';
|
||
return;
|
||
}
|
||
|
||
if (data.has_customer) {
|
||
host.innerHTML = '<div class="small text-success">Email har allerede kunde-link.</div>';
|
||
return;
|
||
}
|
||
|
||
if (data.ignored) {
|
||
host.innerHTML = `<div class="small text-muted">Forslag ignoreret (${escapeHtml(data.reason || 'ukendt')})</div>`;
|
||
return;
|
||
}
|
||
|
||
if (!data.suggestion) {
|
||
host.innerHTML = '<div class="small text-muted">Ingen match på domæne</div>';
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = `
|
||
<div class="emails-v2-kv"><div class="k">Domæne</div><div class="v">${escapeHtml(data.domain || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Kunde</div><div class="v">${escapeHtml(data.suggestion.customer_name || '-')} (#${escapeHtml(String(data.suggestion.customer_id || '-'))})</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Sikkerhed</div><div class="v">${escapeHtml(data.suggestion.confidence || '-')} • score ${escapeHtml(String(data.suggestion.score || 0))}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Kilde</div><div class="v">${escapeHtml(data.suggestion.source || '-')}</div></div>
|
||
<button id="v2ApplyDomainSuggestion" class="btn btn-sm btn-outline-primary mt-2">Anvend kunde-link</button>
|
||
`;
|
||
|
||
document.getElementById('v2ApplyDomainSuggestion')?.addEventListener('click', applyDomainCustomerSuggestion);
|
||
}
|
||
|
||
async function loadDomainCustomerSuggestionCurrent() {
|
||
if (!state.selectedEmailId) return;
|
||
try {
|
||
setDomainStatus('Henter domæneforslag...');
|
||
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`);
|
||
state.domainCustomerSuggestion = suggestion || null;
|
||
renderDomainCustomerSuggestion();
|
||
renderClassificationDecision();
|
||
setDomainStatus('Forslag opdateret', 'success');
|
||
} catch (error) {
|
||
state.domainCustomerSuggestion = {
|
||
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
|
||
suggestion: null,
|
||
};
|
||
renderClassificationDecision();
|
||
setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function applyDomainCustomerSuggestion() {
|
||
if (!state.selectedEmailId) return;
|
||
const suggestion = state.domainCustomerSuggestion?.suggestion;
|
||
if (!suggestion?.customer_id) return;
|
||
|
||
try {
|
||
setDomainStatus('Anvender kunde-link...');
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ customer_id: Number(suggestion.customer_id) }),
|
||
});
|
||
await selectEmail(state.selectedEmailId);
|
||
setDomainStatus('Kunde-link anvendt', 'success');
|
||
} catch (error) {
|
||
setDomainStatus(`Kunne ikke anvende forslag: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function createCustomerFromSuggestion() {
|
||
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||
const email = state.selectedEmail;
|
||
const domain = state.domainCustomerSuggestion?.domain
|
||
|| String(email.sender_email || '').split('@')[1]
|
||
|| null;
|
||
const name = String(email.sender_name || domain || '').trim();
|
||
if (!name) {
|
||
setDetailStatus('Kunden kan ikke oprettes uden et navn');
|
||
return;
|
||
}
|
||
try {
|
||
setDetailStatus(`Opretter kunden ${name}…`);
|
||
const customer = await apiFetch('/api/v1/customers', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name,
|
||
email: email.sender_email || null,
|
||
email_domain: domain,
|
||
is_active: true,
|
||
country: 'DK',
|
||
}),
|
||
});
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ customer_id: Number(customer.id) }),
|
||
});
|
||
setDetailStatus(`Kunden ${name} blev oprettet og tilknyttet`);
|
||
await selectEmail(state.selectedEmailId);
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke oprette kunden: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function linkVendorSuggestion() {
|
||
const vendorId = Number(state.vendorSuggestion?.vendor_id || 0);
|
||
if (!state.selectedEmailId || !vendorId) return;
|
||
try {
|
||
setDetailStatus('Tilknytter leverandøren…');
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ supplier_id: vendorId }),
|
||
});
|
||
setDetailStatus('Leverandøren blev tilknyttet');
|
||
await selectEmail(state.selectedEmailId);
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke tilknytte leverandøren: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function createVendorFromSuggestion() {
|
||
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||
const email = state.selectedEmail;
|
||
const suggestion = state.vendorSuggestion || {};
|
||
const senderDomain = String(email.sender_email || '').split('@')[1] || null;
|
||
const name = String(suggestion.name || email.sender_name || senderDomain || '').trim();
|
||
if (!name) {
|
||
setDetailStatus('Leverandøren kan ikke oprettes uden et navn');
|
||
return;
|
||
}
|
||
try {
|
||
setDetailStatus(`Opretter leverandøren ${name}…`);
|
||
const vendor = await apiFetch('/api/v1/vendors', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name,
|
||
cvr_number: suggestion.cvr_number || null,
|
||
email: suggestion.email || email.sender_email || null,
|
||
phone: suggestion.phone || null,
|
||
address: suggestion.address || null,
|
||
domain: suggestion.domain || senderDomain,
|
||
category: 'supplier',
|
||
notes: `Oprettet fra email #${state.selectedEmailId}`,
|
||
is_active: true,
|
||
}),
|
||
});
|
||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ supplier_id: Number(vendor.id) }),
|
||
});
|
||
setDetailStatus(`Leverandøren ${name} blev oprettet og tilknyttet`);
|
||
await selectEmail(state.selectedEmailId);
|
||
} catch (error) {
|
||
setDetailStatus(`Kunne ikke oprette leverandøren: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function searchSager(query) {
|
||
const emailId = state.selectedEmailId;
|
||
const host = document.getElementById('v2SagResults');
|
||
if (!host) return;
|
||
|
||
const q = String(query || '').trim();
|
||
if (q.length < 2) {
|
||
host.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = '<div class="p-2 small text-muted">Søger...</div>';
|
||
try {
|
||
const rows = await apiFetch(`/api/v1/emails/search-sager?q=${encodeURIComponent(q)}&limit=15`);
|
||
if (state.selectedEmailId !== emailId || document.getElementById('v2SagResults') !== host
|
||
|| document.getElementById('v2SagSearch')?.value.trim() !== q) return;
|
||
const list = Array.isArray(rows) ? rows : [];
|
||
if (!list.length) {
|
||
host.innerHTML = '<div class="p-2 small text-muted">Ingen sager fundet</div>';
|
||
return;
|
||
}
|
||
|
||
host.innerHTML = list.map((item) => `
|
||
<div class="emails-v2-sag-result" data-sag-id="${Number(item.id)}">
|
||
<div class="fw-semibold">#${Number(item.id)} ${escapeHtml(item.titel || 'Uden titel')}</div>
|
||
<div class="small text-muted">${escapeHtml(item.customer_name || 'Ukendt kunde')} • ${escapeHtml(item.status || '-')}</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
host.querySelectorAll('[data-sag-id]').forEach((node) => {
|
||
node.addEventListener('click', () => {
|
||
linkSelectedSag(Number(node.getAttribute('data-sag-id')));
|
||
});
|
||
});
|
||
} catch (error) {
|
||
host.innerHTML = `<div class="p-2 small text-danger">Fejl: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderDetail(email) {
|
||
const mailHeader = document.getElementById('v2MailHeader');
|
||
const quickActions = document.getElementById('v2QuickActions');
|
||
const mailBody = document.getElementById('v2MailBody');
|
||
const sideActions = document.getElementById('v2SideActions');
|
||
if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
|
||
|
||
if (!email) {
|
||
mailHeader.innerHTML = 'Vælg en email for at se info';
|
||
quickActions.innerHTML = '';
|
||
mailBody.className = 'emails-v2-detail-empty';
|
||
mailBody.textContent = 'Vælg en email fra listen';
|
||
sideActions.className = 'emails-v2-detail-empty';
|
||
sideActions.textContent = 'Vælg en email for handlinger';
|
||
setMailStatus('Ingen email valgt');
|
||
return;
|
||
}
|
||
|
||
const headerLink = email.linked_case_id
|
||
? `<a href="/sag/${Number(email.linked_case_id)}/v3">SAG #${Number(email.linked_case_id)}</a>`
|
||
: 'Ikke linket til sag';
|
||
|
||
mailHeader.innerHTML = `
|
||
<div class="emails-v2-subject">${escapeHtml(email.subject || '(Ingen emne)')}</div>
|
||
<div class="emails-v2-topmeta mb-0">
|
||
<span>Fra: ${escapeHtml(email.sender_name || '')} <${escapeHtml(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 classification = String(email.classification || '').toLowerCase();
|
||
const recommendedFlow = classification === 'invoice'
|
||
? 'create_supplier_invoice'
|
||
: (classification === 'customer_email' ? 'create_customer_case' : '');
|
||
const recommendationBadge = (flow) => recommendedFlow === flow
|
||
? '<span class="flow-badge">ANBEFALET</span>'
|
||
: '';
|
||
const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : '';
|
||
|
||
quickActions.className = 'emails-v2-quick-toolbar';
|
||
quickActions.innerHTML = email.linked_case_id ? `
|
||
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
||
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||
</a>
|
||
` : `
|
||
<div class="emails-v2-flow-picker">
|
||
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
|
||
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow">
|
||
<button class="emails-v2-flow-choice flow-support${recommendationClass('create_customer_case')}" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
|
||
${recommendationBadge('create_customer_case')}
|
||
<span class="flow-title"><i class="bi bi-headset me-1"></i>Support</span>
|
||
<span class="flow-description">Opret en almindelig kundesag</span>
|
||
</button>
|
||
<button class="emails-v2-flow-choice flow-invoice${recommendationClass('create_supplier_invoice')}" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
|
||
${recommendationBadge('create_supplier_invoice')}
|
||
<span class="flow-title"><i class="bi bi-receipt me-1"></i>Leverandørfaktura</span>
|
||
<span class="flow-description">Aflæs fakturabilag og kør leverandørflowet</span>
|
||
</button>
|
||
<button class="emails-v2-flow-choice flow-accounting${recommendationClass('create_accounting_case')}" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
|
||
${recommendationBadge('create_accounting_case')}
|
||
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
|
||
<span class="flow-description">Opret kundesag hos Bogholderi</span>
|
||
</button>
|
||
<button class="emails-v2-flow-choice flow-archive${recommendationClass('archive_entity_mail')}" data-v2-quick-action="archive_entity_mail" title="Arkiverer kun mailen; opretter intet">
|
||
${recommendationBadge('archive_entity_mail')}
|
||
<span class="flow-title"><i class="bi bi-archive me-1"></i>Kun arkivér</span>
|
||
<span class="flow-description">Ingen sag eller anden behandling</span>
|
||
</button>
|
||
<button id="v2LinkCaseAction" class="emails-v2-flow-choice flow-case">
|
||
<span class="flow-title"><i class="bi bi-link-45deg me-1"></i>Knyt til sag</span>
|
||
<span class="flow-description">Find og vælg en eksisterende sag</span>
|
||
</button>
|
||
${hasSupplier ? `<button id="v2SupplierCaseAction" class="emails-v2-flow-choice flow-case">
|
||
<span class="flow-title"><i class="bi bi-truck me-1"></i>Leverandørsag</span>
|
||
<span class="flow-description">Opret sag til dialog med leverandøren</span>
|
||
</button>` : ''}
|
||
<button type="button" data-purchase-kind="order_confirmation" class="emails-v2-flow-choice flow-purchase">
|
||
<span class="flow-title"><i class="bi bi-bag-check me-1"></i>Ordrebekræftelse</span>
|
||
<span class="flow-description">Knyt mailen til en bestilling</span>
|
||
</button>
|
||
<button type="button" data-purchase-kind="freight_note" class="emails-v2-flow-choice flow-purchase">
|
||
<span class="flow-title"><i class="bi bi-box-seam me-1"></i>Levering / tracking</span>
|
||
<span class="flow-description">Vælg bestilling og tilføj tracking</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
mailBody.className = 'emails-v2-mail-body';
|
||
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
|
||
const rawHtml = String(email.body_html || '').trim();
|
||
const sanitizedHtml = sanitizeEmailHtml(rawHtml);
|
||
const renderedMessage = sanitizedHtml || formatEmailPlainText(email.body_text || 'Ingen indhold');
|
||
const messageClass = sanitizedHtml ? 'emails-v2-body html' : 'emails-v2-body';
|
||
|
||
mailBody.innerHTML = `
|
||
<div class="emails-v2-card">
|
||
<h6>Besked</h6>
|
||
<div class="${messageClass}">${renderedMessage}</div>
|
||
</div>
|
||
|
||
${hasAttachments ? `
|
||
<div class="emails-v2-card">
|
||
<h6>Vedhæftninger (${email.attachments.length})</h6>
|
||
<div class="emails-v2-attachments">
|
||
${email.attachments.map((att) => {
|
||
const name = att.filename || `Vedhæftning ${att.id}`;
|
||
return `<a class="btn btn-sm btn-outline-secondary" href="/api/v1/emails/${Number(email.id)}/attachments/${Number(att.id)}">${escapeHtml(name)}</a>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
`;
|
||
|
||
sideActions.className = 'emails-v2-actions-pane';
|
||
sideActions.innerHTML = `
|
||
<div id="v2SenderIdentity" class="emails-v2-card"></div>
|
||
<div id="v2CreateSenderCard" class="emails-v2-card" hidden></div>
|
||
<form id="v2PurchaseMailForm" class="emails-v2-card" hidden>
|
||
<h6 id="v2PurchaseMailTitle">Ordre og levering</h6>
|
||
<label class="small fw-semibold" for="v2PurchaseSearch">Vælg bestilling</label>
|
||
<input id="v2PurchaseSearch" class="form-control form-control-sm mb-2" placeholder="Søg vare, reference, kunde eller sag" autocomplete="off">
|
||
<div class="small text-muted mb-2">Seneste bestillinger vises først. Søg for at finde ældre.</div>
|
||
<div id="v2PurchaseOptions" style="max-height:240px;overflow:auto"></div>
|
||
<div id="v2PurchaseSelected" class="small fw-semibold my-2"></div>
|
||
<div id="v2PurchaseTrackingField" hidden>
|
||
<label class="small" for="v2PurchaseTracking">Trackingnummer (valgfrit)</label>
|
||
<input id="v2PurchaseTracking" maxlength="100" class="form-control form-control-sm mb-2">
|
||
</div>
|
||
<div class="small text-muted mb-2">Mailen knyttes til bestillingen og sagen. Varen markeres ikke som modtaget.</div>
|
||
<button id="v2PurchaseSubmit" class="btn btn-primary btn-sm" type="submit" disabled>Knyt mail til bestilling</button>
|
||
<button id="v2PurchaseCancel" class="btn btn-outline-secondary btn-sm" type="button">Annuller</button>
|
||
<div id="v2PurchaseStatus" class="small mt-2" role="status"></div>
|
||
</form>
|
||
${hasSupplier && !email.linked_case_id ? `
|
||
<form id="v2SupplierCaseForm" class="emails-v2-card" hidden>
|
||
<h6>Ny leverandørsag</h6>
|
||
<div class="small text-muted mb-2">Dialog med ${escapeHtml(email.supplier_name || 'leverandøren')}. Mailen og dens bilag knyttes til sagen.</div>
|
||
<label for="v2SupplierCaseCustomer" class="small fw-semibold">Hvem er sagen for?</label>
|
||
<input id="v2SupplierCaseCustomer" class="form-control form-control-sm mb-1" autocomplete="off" value="${escapeHtml(email.customer_name || '')}" placeholder="Søg kunde — eller BMC til en intern sag">
|
||
<div id="v2SupplierCaseCustomers"></div>
|
||
<label for="v2SupplierCaseTitle" class="small fw-semibold mt-2">Titel</label>
|
||
<input id="v2SupplierCaseTitle" class="form-control form-control-sm mb-2" required value="${escapeHtml(email.subject || '')}">
|
||
<div class="d-flex gap-2">
|
||
<button id="v2SupplierCaseSubmit" type="submit" class="btn btn-primary btn-sm" ${hasCustomer ? '' : 'disabled'}>Opret leverandørsag</button>
|
||
<button id="v2SupplierCaseCancel" type="button" class="btn btn-outline-secondary btn-sm">Annuller</button>
|
||
</div>
|
||
<div id="v2SupplierCaseStatus" class="small mt-2" role="status"></div>
|
||
</form>` : ''}
|
||
<div class="emails-v2-card">
|
||
<h6>Sag</h6>
|
||
${email.linked_case_id ? `<a href="/sag/${Number(email.linked_case_id)}/v3">Åbn SAG #${Number(email.linked_case_id)}</a><div class="small text-muted mt-1">Mailen er knyttet til sagen.</div>` : '<div class="small text-muted">Ingen sag tilknyttet.</div>'}
|
||
<div id="v2CaseLinkPicker" class="mt-3">
|
||
<label for="v2SagSearch" class="small fw-semibold">${email.linked_case_id ? 'Skift sag' : 'Knyt til eksisterende sag'}</label>
|
||
<input id="v2SagSearch" class="form-control form-control-sm mt-2" placeholder="Sagsnummer eller titel…">
|
||
<div id="v2SagResults" class="emails-v2-sag-results"></div>
|
||
</div>
|
||
</div>
|
||
<details class="emails-v2-card"><summary class="small">Mailvurdering</summary>
|
||
<div id="v2DecisionCard" class="email-ai-decision">
|
||
<div class="d-flex align-items-center gap-2 text-muted">
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
Systemet vurderer mailtype og handlinger…
|
||
</div>
|
||
</div>
|
||
|
||
</details>
|
||
<details class="emails-v2-card">
|
||
<summary class="fw-semibold" style="cursor:pointer;">
|
||
<i class="bi bi-three-dots me-1"></i>Flere handlinger
|
||
</summary>
|
||
<div class="mt-3">
|
||
|
||
${email.linked_case_id ? `
|
||
<div class="emails-primary-action rounded p-3 mb-3">
|
||
<h6>Allerede knyttet til sag</h6>
|
||
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
||
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||
</a>
|
||
<button id="v2Processed" class="btn btn-sm btn-outline-success w-100 mt-2">
|
||
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
|
||
</button>
|
||
</div>` : `
|
||
<details class="border rounded p-3 mb-3">
|
||
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary>
|
||
<div class="mt-3">
|
||
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
|
||
<div class="d-flex gap-2">
|
||
<select id="v2CaseType" class="form-select form-select-sm">
|
||
<option value="support" ${!email.supplier_id ? 'selected' : ''}>Support</option>
|
||
<option value="bogholderi">Bogholderi</option>
|
||
<option value="leverandor" ${email.supplier_id ? 'selected' : ''}>Leverandør</option>
|
||
<option value="helhedsopgave">Projekt/helhedsopgave</option>
|
||
<option value="andet">Andet</option>
|
||
</select>
|
||
<button id="v2CreateCase" class="btn btn-primary text-nowrap">
|
||
<i class="bi bi-plus-lg me-1"></i>Opret
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</details>`}
|
||
|
||
<div class="border rounded p-3 mb-3">
|
||
<h6>Hurtige mailhandlinger</h6>
|
||
<div class="emails-v2-actions mb-0">
|
||
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi bi-envelope${email.is_read ? '' : '-open'} me-1"></i>${email.is_read ? 'Ulæst' : 'Læst'}
|
||
</button>
|
||
<button id="v2Archive" class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi bi-archive me-1"></i>Arkivér
|
||
</button>
|
||
${email.linked_case_id ? '' : `
|
||
<button id="v2Processed" class="btn btn-sm btn-outline-success">
|
||
<i class="bi bi-check2 me-1"></i>Behandlet
|
||
</button>`}
|
||
</div>
|
||
</div>
|
||
|
||
<details class="border rounded p-3 mb-3">
|
||
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
|
||
<div class="emails-v2-actions mt-3 mb-2">
|
||
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
|
||
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
|
||
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
|
||
<button id="v2AutoParse" class="btn btn-sm btn-outline-primary">Find tråd/sag automatisk</button>
|
||
</div>
|
||
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
|
||
</details>
|
||
|
||
<details class="border rounded p-3 mb-3">
|
||
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
|
||
<h6 class="mt-3">Leverandør faktura</h6>
|
||
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Beløb</div><div class="v">${escapeHtml(String(email.extracted_amount || '-'))}</div></div>
|
||
<div class="emails-v2-kv"><div class="k">Forfald</div><div class="v">${escapeHtml(email.extracted_due_date || '-')}</div></div>
|
||
<div class="emails-v2-right-actions mt-2">
|
||
<button id="v2ExtractVendor" class="btn btn-sm btn-outline-warning">Udtræk leverandørforslag</button>
|
||
</div>
|
||
<div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div>
|
||
<div id="v2SupplierStatus" class="emails-v2-inline-status"></div>
|
||
<hr>
|
||
<h6>Automatisk kundematch</h6>
|
||
<div class="emails-v2-right-actions">
|
||
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
|
||
</div>
|
||
<div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div>
|
||
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
|
||
</details>
|
||
|
||
<details class="border rounded p-3">
|
||
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
|
||
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
|
||
email_id: email.id,
|
||
message_id: email.message_id,
|
||
in_reply_to: email.in_reply_to,
|
||
email_references: email.email_references,
|
||
classification: email.classification,
|
||
confidence_score: email.confidence_score,
|
||
linked_case_id: email.linked_case_id,
|
||
}, null, 2))}</pre>
|
||
</details>
|
||
</div>
|
||
</details>
|
||
`;
|
||
|
||
// Identity and case handling remain visible; secondary actions keep their
|
||
// native <details> disclosure so the primary task stays focused.
|
||
document.querySelectorAll('[data-purchase-kind]').forEach(button => button.addEventListener('click', () => openPurchaseMail(button.dataset.purchaseKind)));
|
||
document.getElementById('v2PurchaseMailForm')?.addEventListener('submit', submitPurchaseMail);
|
||
document.getElementById('v2PurchaseCancel')?.addEventListener('click', () => {document.getElementById('v2PurchaseMailForm').hidden = true;});
|
||
document.getElementById('v2PurchaseSearch')?.addEventListener('input', () => {
|
||
state.selectedPurchase = null;
|
||
document.getElementById('v2PurchaseSelected').textContent = '';
|
||
document.getElementById('v2PurchaseSubmit').disabled = true;
|
||
clearTimeout(state.purchaseSearchTimer);
|
||
state.purchaseSearchTimer = setTimeout(searchPurchaseOptions, 220);
|
||
});
|
||
state.supplierCaseCustomer = hasCustomer ? {id: Number(email.customer_id), name: email.customer_name} : null;
|
||
document.getElementById('v2LinkCaseAction')?.addEventListener('click', openCaseLinkPicker);
|
||
document.getElementById('v2SupplierCaseAction')?.addEventListener('click', openSupplierCase);
|
||
document.getElementById('v2SupplierCaseForm')?.addEventListener('submit', createSupplierCase);
|
||
document.getElementById('v2SupplierCaseCancel')?.addEventListener('click', () => {
|
||
document.getElementById('v2SupplierCaseForm').hidden = true;
|
||
});
|
||
document.getElementById('v2SupplierCaseCustomer')?.addEventListener('input', () => {
|
||
state.supplierCaseCustomer = null;
|
||
document.getElementById('v2SupplierCaseSubmit').disabled = true;
|
||
document.getElementById('v2SupplierCaseCustomers').innerHTML = '';
|
||
clearTimeout(state.supplierCustomerTimer);
|
||
state.supplierCustomerTimer = setTimeout(searchSupplierCaseCustomer, 220);
|
||
});
|
||
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));
|
||
document.getElementById('v2Archive')?.addEventListener('click', archiveCurrent);
|
||
document.getElementById('v2Processed')?.addEventListener('click', markProcessedCurrent);
|
||
document.getElementById('v2Reprocess')?.addEventListener('click', reprocessCurrent);
|
||
document.getElementById('v2ExecuteWorkflows')?.addEventListener('click', executeWorkflowsCurrent);
|
||
document.getElementById('v2AutoRun')?.addEventListener('click', autoRunWorkflowsCurrent);
|
||
document.getElementById('v2AutoParse')?.addEventListener('click', autoParseAndLinkCurrent);
|
||
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
|
||
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
|
||
document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent);
|
||
document.querySelectorAll('[data-v2-quick-action]').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
runQuickAction(button.getAttribute('data-v2-quick-action'), button);
|
||
});
|
||
});
|
||
|
||
loadSenderIdentity();
|
||
state.vendorSuggestion = null;
|
||
state.domainCustomerSuggestion = null;
|
||
state.workflowPreview = null;
|
||
renderVendorSuggestion();
|
||
renderDomainCustomerSuggestion();
|
||
renderWorkflowPreview();
|
||
loadWorkflowPreviewCurrent();
|
||
setMailStatus(`Email #${email.id} vises`);
|
||
|
||
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
|
||
const query = event.target.value;
|
||
clearTimeout(state.sagSearchTimer);
|
||
state.sagSearchTimer = setTimeout(() => searchSager(query), 220);
|
||
});
|
||
}
|
||
|
||
async function uploadEmailFiles(files) {
|
||
const accepted = Array.from(files || []).filter((file) =>
|
||
/\.(eml|msg)$/i.test(file.name || '')
|
||
);
|
||
if (!accepted.length) {
|
||
setListStatus('Vælg .eml- eller .msg-filer');
|
||
return;
|
||
}
|
||
|
||
const zone = document.getElementById('v2UploadZone');
|
||
const label = document.getElementById('v2UploadLabel');
|
||
const original = label?.innerHTML;
|
||
if (zone) {
|
||
zone.classList.add('busy');
|
||
}
|
||
if (label) {
|
||
label.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>Importerer ${accepted.length} mail${accepted.length === 1 ? '' : 's'}…`;
|
||
}
|
||
try {
|
||
const formData = new FormData();
|
||
accepted.forEach((file) => formData.append('files', file));
|
||
const result = await apiFetch('/api/v1/emails/upload', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
const uploaded = Number(result?.uploaded || 0);
|
||
const duplicates = Number(result?.duplicates || 0);
|
||
setListStatus(`${uploaded} importeret${duplicates ? ` · ${duplicates} fandtes allerede` : ''}`);
|
||
state.filter = 'active';
|
||
state.selectedEmailId = null;
|
||
await loadEmails();
|
||
} catch (error) {
|
||
setListStatus(`Import fejlede: ${error.message}`);
|
||
} finally {
|
||
if (zone) {
|
||
zone.classList.remove('busy', 'dragover');
|
||
}
|
||
if (label) label.innerHTML = original;
|
||
}
|
||
}
|
||
|
||
function setupUploadZone() {
|
||
const zone = document.getElementById('v2UploadZone');
|
||
const input = document.getElementById('v2UploadInput');
|
||
if (!zone || !input || zone.dataset.ready === 'true') return;
|
||
zone.dataset.ready = 'true';
|
||
zone.addEventListener('click', () => input.click());
|
||
zone.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
input.click();
|
||
}
|
||
});
|
||
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
zone.classList.add('dragover');
|
||
}));
|
||
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
zone.classList.remove('dragover');
|
||
}));
|
||
zone.addEventListener('drop', (event) => uploadEmailFiles(event.dataTransfer?.files));
|
||
input.addEventListener('change', () => {
|
||
uploadEmailFiles(input.files);
|
||
input.value = '';
|
||
});
|
||
}
|
||
|
||
function selectAdjacentEmail(offset) {
|
||
if (!state.emails.length) return;
|
||
const current = state.emails.findIndex((email) => Number(email.id) === Number(state.selectedEmailId));
|
||
const next = Math.min(Math.max((current < 0 ? 0 : current) + offset, 0), state.emails.length - 1);
|
||
selectEmail(Number(state.emails[next].id));
|
||
}
|
||
|
||
function setupKeyboardShortcuts() {
|
||
document.addEventListener('keydown', (event) => {
|
||
const target = event.target;
|
||
const typing = target instanceof HTMLInputElement
|
||
|| target instanceof HTMLTextAreaElement
|
||
|| target instanceof HTMLSelectElement
|
||
|| target?.isContentEditable;
|
||
if (event.key === '/' && !typing) {
|
||
event.preventDefault();
|
||
document.getElementById('v2Search')?.focus();
|
||
} else if (!typing && event.key.toLowerCase() === 'j') {
|
||
selectAdjacentEmail(1);
|
||
} else if (!typing && event.key.toLowerCase() === 'k') {
|
||
selectAdjacentEmail(-1);
|
||
} else if (!typing && event.key.toLowerCase() === 'r') {
|
||
loadEmails();
|
||
}
|
||
});
|
||
}
|
||
|
||
function setupEvents() {
|
||
document.getElementById('v2Search')?.addEventListener('input', (event) => {
|
||
const value = String(event.target.value || '').trim();
|
||
clearTimeout(state.searchTimer);
|
||
state.searchTimer = setTimeout(() => {
|
||
state.query = value;
|
||
state.selectedEmailId = null;
|
||
state.selectedEmail = null;
|
||
renderDetail(null);
|
||
loadEmails();
|
||
}, 250);
|
||
});
|
||
|
||
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
|
||
setupUploadZone();
|
||
setupKeyboardShortcuts();
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
setupEvents();
|
||
renderFilters();
|
||
renderDetail(null);
|
||
await loadEmails();
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|