bmc_hub/app/emails/frontend/emails.html

4831 lines
185 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

{% extends "shared/frontend/base.html" %}
{% block title %}Email - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Email Layout - 3 Column Grid */
.email-container {
display: flex;
gap: 1rem;
height: calc(100vh - 140px);
overflow: hidden;
}
.email-container > * {
min-width: 0;
}
/* Left Sidebar - Email List (25%) */
.email-list-sidebar {
flex: 0 0 400px;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: var(--border-radius);
overflow: hidden;
}
.email-list-header {
padding: 1rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.email-list-filters {
padding: 0.5rem 1rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-pill {
background: var(--bg-body);
border: 1px solid rgba(0,0,0,0.1);
color: var(--text-secondary);
padding: 0.4rem 0.9rem;
border-radius: 20px;
font-size: 0.85rem;
transition: all 0.2s;
cursor: pointer;
white-space: nowrap;
}
.filter-pill:hover, .filter-pill.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.filter-pill .count {
opacity: 0.7;
margin-left: 0.3rem;
}
.email-list-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.email-item {
padding: 1rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
cursor: pointer;
transition: all 0.2s;
display: flex;
gap: 0.75rem;
}
.email-item:hover:not(.active) {
background: rgba(15, 76, 117, 0.08);
}
.email-item.unread:not(.active) {
background: rgba(15, 76, 117, 0.05);
border-left: 3px solid var(--accent);
}
.email-item.unread .email-subject {
font-weight: 700;
}
.email-item.active {
background: var(--accent) !important;
border-left: 4px solid #0a3a5c !important;
}
.email-item.active .email-subject,
.email-item.active .email-sender,
.email-item.active .email-preview,
.email-item.active .email-time {
color: white !important;
}
.email-item.active .badge {
background: rgba(255, 255, 255, 0.2) !important;
color: white !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
.email-item.active .sender-avatar {
border: 2px solid rgba(255, 255, 255, 0.3);
}
.sender-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.85rem;
flex-shrink: 0;
}
.email-item.active .sender-avatar {
background: rgba(255,255,255,0.2);
color: white;
}
.email-item-content {
flex: 1;
min-width: 0;
}
.email-item-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.3rem;
}
.email-sender {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-time {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
margin-left: 0.5rem;
}
.email-subject {
font-size: 0.9rem;
color: var(--text-primary);
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-preview {
font-size: 0.8rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.3rem;
}
.email-meta {
display: flex;
gap: 0.5rem;
align-items: center;
}
.classification-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-weight: 500;
}
.classification-invoice { background: #d4edda; color: #155724; }
.classification-order_confirmation { background: #d1ecf1; color: #0c5460; }
.classification-freight_note { background: #fff3cd; color: #856404; }
.classification-time_confirmation { background: #e2e3e5; color: #383d41; }
.classification-case_notification { background: #cce5ff; color: #004085; }
.classification-bankruptcy { background: #f8d7da; color: #721c24; }
.classification-spam { background: #343a40; color: #fff; }
.classification-general { background: #e9ecef; color: #495057; }
[data-bs-theme="dark"] .classification-badge {
opacity: 0.9;
}
.unread-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 0.3rem;
}
/* Center Pane - Email Content (50%) */
.email-content-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: var(--border-radius);
overflow: hidden;
}
.email-content-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
min-width: 0;
}
.email-content-subject {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-content-meta {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
min-width: 0;
}
.sender-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.sender-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.sender-name {
font-weight: 600;
font-size: 0.95rem;
}
.sender-email {
font-size: 0.8rem;
color: var(--text-secondary);
overflow-wrap: anywhere;
word-break: break-word;
}
.email-timestamp {
color: var(--text-secondary);
font-size: 0.85rem;
}
.email-actions {
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
background: var(--bg-body);
flex-wrap: wrap;
overflow-x: hidden;
}
.attachment-chip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-actions .btn-primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.email-actions .btn-primary:hover {
background: #0a3a5c;
border-color: #0a3a5c;
}
.email-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
overflow-x: hidden;
line-height: 1.6;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-body pre,
.email-html-body,
.email-html-body * {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-html-body table {
display: block;
overflow-x: auto;
width: 100%;
}
.email-body iframe {
width: 100%;
border: none;
min-height: 400px;
}
.email-attachments {
padding: 1rem 1.5rem;
border-top: 1px solid rgba(0,0,0,0.1);
background: var(--bg-body);
}
.attachment-item {
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
margin-bottom: 0.5rem;
transition: all 0.2s;
color: var(--text-primary);
min-width: 0;
}
.attachment-item .flex-grow-1 {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-item:hover {
background: var(--bg-hover);
border-color: var(--accent);
}
.email-attachments {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(0,0,0,0.1);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
.empty-state i {
font-size: 4rem;
opacity: 0.3;
margin-bottom: 1rem;
}
/* Right Sidebar - AI Analysis (25%) */
.email-analysis-sidebar {
flex: 0 0 380px;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
min-width: 0;
}
.analysis-card {
background: var(--bg-card);
border-radius: var(--border-radius);
padding: 1.25rem;
}
.analysis-card h6 {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.confidence-meter {
margin-bottom: 1rem;
}
.confidence-bar {
height: 8px;
background: rgba(0,0,0,0.1);
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}
.confidence-fill {
height: 100%;
background: linear-gradient(90deg, #dc3545, #ffc107, #28a745);
transition: width 0.3s;
}
.confidence-label {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.3rem;
}
.classification-select {
width: 100%;
padding: 0.6rem;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
color: var(--text-primary);
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
.metadata-list {
list-style: none;
padding: 0;
margin: 0;
}
.metadata-item {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
font-size: 0.85rem;
}
.metadata-item:last-child {
border-bottom: none;
}
.metadata-label {
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.2rem;
}
.metadata-value {
color: var(--text-primary);
font-weight: 500;
}
.rules-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--accent-light);
border-radius: 8px;
font-size: 0.85rem;
color: var(--accent);
margin-bottom: 0.75rem;
}
.suggestion-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.suggestion-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.suggestion-field label {
font-size: 0.72rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.suggestion-field input,
.suggestion-field select {
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
color: var(--text-primary);
padding: 0.45rem 0.6rem;
font-size: 0.85rem;
}
.suggestion-field.full {
grid-column: 1 / -1;
}
.quick-action-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.customer-search-wrap {
position: relative;
}
.customer-search-results {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.12);
border-radius: 10px;
max-height: 260px;
overflow-y: auto;
z-index: 12;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.customer-search-item {
padding: 0.55rem 0.65rem;
border-bottom: 1px solid rgba(0,0,0,0.06);
cursor: pointer;
}
.customer-search-item:last-child {
border-bottom: none;
}
.customer-search-item:hover {
background: var(--accent-light);
}
.customer-search-name {
font-size: 0.85rem;
font-weight: 600;
}
.customer-search-meta {
font-size: 0.74rem;
color: var(--text-secondary);
}
/* Responsive Design */
@media (max-width: 1200px) {
.email-list-sidebar {
flex: 0 0 280px;
}
.email-analysis-sidebar {
flex: 0 0 260px;
}
}
@media (max-width: 992px) {
.email-container {
flex-direction: column;
height: auto;
}
.email-list-sidebar,
.email-content-pane,
.email-analysis-sidebar {
flex: 0 0 auto;
max-height: 600px;
}
}
@media (max-width: 768px) {
.email-container {
gap: 0.5rem;
}
.email-list-sidebar {
max-height: 400px;
}
.email-analysis-sidebar {
display: none;
}
.filter-pill {
font-size: 0.75rem;
padding: 0.3rem 0.7rem;
}
}
/* Keyboard Navigation Hint */
.keyboard-hint {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg-card);
padding: 0.5rem 1rem;
border-radius: 20px;
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
font-size: 0.8rem;
color: var(--text-secondary);
opacity: 0.7;
transition: opacity 0.2s;
}
.keyboard-hint:hover {
opacity: 1;
}
/* Loading States */
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem;
}
/* Bulk Actions Toolbar */
.bulk-actions-toolbar {
display: none;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--accent);
color: white;
border-radius: var(--border-radius);
margin-bottom: 1rem;
}
/* Workflow Step Builder */
.workflow-step-item {
background: white;
border: 2px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 0.75rem;
transition: all 0.2s;
position: relative;
}
.workflow-step-item:hover {
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.1);
}
.workflow-step-item.dragging {
opacity: 0.5;
border-style: dashed;
}
.step-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
cursor: grab;
background: linear-gradient(to right, #f8f9fa, white);
border-radius: 6px 6px 0 0;
}
.step-header:active {
cursor: grabbing;
}
.step-number {
background: var(--accent);
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
flex-shrink: 0;
}
.step-icon {
font-size: 1.25rem;
color: var(--accent);
}
.step-body {
padding: 1rem;
border-top: 1px solid #e0e0e0;
}
.step-param-row {
margin-bottom: 0.75rem;
}
.step-param-row:last-child {
margin-bottom: 0;
}
.step-actions {
display: flex;
gap: 0.5rem;
}
.drag-handle {
cursor: grab;
color: var(--text-secondary);
}
.drag-handle:active {
cursor: grabbing;
}
.step-connector {
width: 2px;
height: 20px;
background: linear-gradient(to bottom, var(--accent), transparent);
margin: 0 auto;
}
.bulk-actions-toolbar.active {
display: flex;
}
.bulk-actions-toolbar .btn {
color: white;
border-color: rgba(255,255,255,0.3);
}
.email-checkbox {
margin-right: 0.5rem;
}
/* Workflow Template Cards */
.workflow-template-card {
transition: all 0.2s;
border: 2px solid transparent;
}
.workflow-template-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.15);
transform: translateY(-2px);
}
.workflow-template-card .card-body {
padding: 1.25rem;
}
.workflow-template-card .badge {
font-size: 0.75rem;
padding: 0.35rem 0.6rem;
}
/* Activity Timeline */
.activity-item {
display: flex;
gap: 1rem;
padding: 1rem 0;
border-left: 2px solid #e0e0e0;
margin-left: 1rem;
position: relative;
}
.activity-item:last-child {
border-left-color: transparent;
}
.activity-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
position: absolute;
left: -17px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.activity-content {
flex: 1;
padding-left: 2rem;
}
.activity-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.25rem;
}
.activity-type {
font-size: 0.9rem;
color: var(--text-primary);
}
.activity-time {
font-size: 0.75rem;
white-space: nowrap;
}
.activity-description {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
}
.activity-metadata summary {
color: var(--accent);
}
.activity-metadata pre {
max-height: 200px;
overflow-y: auto;
}
.activity-user {
font-size: 0.75rem;
}
#emailActivityTimeline {
max-height: calc(100vh - 250px);
overflow-y: auto;
}
/* Email Upload Drop Zone */
.email-upload-zone {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.drop-zone {
border: 2px dashed var(--accent);
border-radius: var(--border-radius);
padding: 1.5rem 1rem;
text-align: center;
background: rgba(15, 76, 117, 0.03);
cursor: pointer;
transition: all 0.3s ease;
}
.drop-zone:hover {
background: rgba(15, 76, 117, 0.08);
border-color: #0a3a5c;
transform: scale(1.02);
}
.drop-zone.dragover {
background: rgba(15, 76, 117, 0.15);
border-color: var(--accent);
border-width: 3px;
transform: scale(1.05);
}
.drop-zone i {
font-size: 2rem;
color: var(--accent);
display: block;
margin-bottom: 0.5rem;
}
.drop-zone p {
margin: 0;
font-size: 0.9rem;
}
.upload-progress {
padding: 0 0.5rem;
}
.upload-progress .progress {
height: 4px;
}
[data-bs-theme="dark"] .drop-zone {
background: rgba(15, 76, 117, 0.1);
border-color: var(--accent-light);
}
[data-bs-theme="dark"] .drop-zone:hover {
background: rgba(15, 76, 117, 0.2);
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">Email</h2>
<p class="text-muted mb-0">Administrer og klassificer emails automatisk</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-light border" onclick="showRulesModal()" title="Email Rules">
<i class="bi bi-gear me-2"></i>Regler
</button>
<button class="btn btn-primary" onclick="manualProcessEmails()" id="processBtn">
<i class="bi bi-arrow-clockwise me-2"></i>Hent Nye
</button>
</div>
</div>
<!-- Bulk Actions Toolbar -->
<div class="bulk-actions-toolbar" id="bulkActionsToolbar">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
<span id="selectedCount">0</span> valgt
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm" onclick="bulkArchive()">
<i class="bi bi-archive me-1"></i>Arkivér
</button>
<button class="btn btn-sm" onclick="bulkReprocess()">
<i class="bi bi-arrow-clockwise me-1"></i>Genbehandl
</button>
<button class="btn btn-sm" onclick="bulkDelete()">
<i class="bi bi-trash me-1"></i>Slet
</button>
<button class="btn btn-sm" onclick="clearSelection()">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Main 3-Column Layout -->
<div class="email-container">
<!-- Left Sidebar - Email List -->
<div class="email-list-sidebar">
<div class="email-list-header">
<div class="d-flex gap-2 align-items-center mb-2">
<input type="text"
class="form-control flex-grow-1"
id="emailSearchInput"
placeholder="Søg emails..."
style="border-radius: 20px;">
<button class="btn btn-outline-primary" onclick="openWorkflowManager()" title="Workflow Management">
<i class="bi bi-diagram-3"></i>
</button>
</div>
</div>
<!-- Email Upload Drop Zone -->
<div class="email-upload-zone" id="emailUploadZone" style="display: none;">
<div class="drop-zone" id="dropZone">
<i class="bi bi-cloud-upload"></i>
<p class="mb-1"><strong>Træk emails hertil</strong></p>
<small class="text-muted">eller klik for at vælge filer</small>
<small class="text-muted d-block mt-1">.eml og .msg filer</small>
<input type="file" id="fileInput" multiple accept=".eml,.msg" style="display: none;">
</div>
<div class="upload-progress mt-2" id="uploadProgress" style="display: none;">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" id="progressBar" style="width: 0%"></div>
</div>
<small class="text-muted d-block text-center mt-1" id="uploadStatus">Uploader...</small>
</div>
</div>
<div class="p-2 border-bottom">
<button class="btn btn-sm btn-outline-secondary w-100" onclick="toggleUploadZone()">
<i class="bi bi-upload me-1"></i> Upload Emails
</button>
</div>
<div class="email-list-filters">
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
Aktive <span class="count" id="countActive">0</span>
</button>
<button class="filter-pill" data-filter="all" onclick="setFilter('all')">
Alle <span class="count" id="countAll">0</span>
</button>
<button class="filter-pill" data-filter="invoice" onclick="setFilter('invoice')">
Faktura <span class="count" id="countInvoice">0</span>
</button>
<button class="filter-pill" data-filter="order_confirmation" onclick="setFilter('order_confirmation')">
Ordre <span class="count" id="countOrder">0</span>
</button>
<button class="filter-pill" data-filter="freight_note" onclick="setFilter('freight_note')">
Fragt <span class="count" id="countFreight">0</span>
</button>
<button class="filter-pill" data-filter="time_confirmation" onclick="setFilter('time_confirmation')">
Tid <span class="count" id="countTime">0</span>
</button>
<button class="filter-pill" data-filter="processed" onclick="setFilter('processed')">
Behandlet <span class="count" id="countProcessed">0</span>
</button>
<button class="filter-pill" data-filter="awaiting_user_action" onclick="setFilter('awaiting_user_action')">
Afventer <span class="count" id="countAwaiting">0</span>
</button>
<button class="filter-pill" data-filter="case_notification" onclick="setFilter('case_notification')">
Sag <span class="count" id="countCase">0</span>
</button>
<button class="filter-pill" data-filter="general" onclick="setFilter('general')">
Generel <span class="count" id="countGeneral">0</span>
</button>
<button class="filter-pill" data-filter="newsletter" onclick="setFilter('newsletter')">
Info/Nyhed <span class="count" id="countNewsletter">0</span>
</button>
<button class="filter-pill" data-filter="spam" onclick="setFilter('spam')">
Spam <span class="count" id="countSpam">0</span>
</button>
</div>
<div class="email-list-body" id="emailListBody">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
<!-- Center Pane - Email Content -->
<div class="email-content-pane" id="emailContentPane">
<div class="empty-state">
<i class="bi bi-envelope"></i>
<p>Vælg en email for at se indholdet</p>
<small class="text-muted">Brug ↑↓ eller j/k til at navigere</small>
</div>
</div>
<!-- Right Sidebar - AI Analysis + Activity Log -->
<div class="email-analysis-sidebar" id="emailAnalysisSidebar">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#aiAnalysisTab">
<i class="bi bi-robot me-1"></i>AI
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#activityLogTab" onclick="loadEmailActivityLog()">
<i class="bi bi-clock-history me-1"></i>Log
</button>
</li>
</ul>
<div class="tab-content">
<!-- AI Analysis Tab -->
<div class="tab-pane fade show active" id="aiAnalysisTab">
<div class="analysis-card">
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
<p class="text-muted small mb-3">Vælg en email for at se analyse</p>
</div>
</div>
<!-- Activity Log Tab -->
<div class="tab-pane fade" id="activityLogTab">
<div class="p-3">
<h6 class="mb-3"><i class="bi bi-clock-history me-2"></i>Email Aktivitet</h6>
<div id="emailActivityTimeline">
<p class="text-muted small">Vælg en email for at se aktivitet</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Keyboard Shortcuts Hint -->
<div class="keyboard-hint">
Tryk <kbd>?</kbd> for genveje
</div>
<!-- Email Rules Modal -->
<div class="modal fade" id="emailRulesModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Email Regler</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<button class="btn btn-primary" onclick="showCreateRuleForm()">
<i class="bi bi-plus-lg me-2"></i>Ny Regel
</button>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Prioritet</th>
<th>Navn</th>
<th>Handling</th>
<th>Status</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="rulesTableBody">
<tr>
<td colspan="5" class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Keyboard Shortcuts Help Modal -->
<div class="modal fade" id="shortcutsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Keyboard Shortcuts</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<h6 class="text-muted">Navigation</h6>
<div class="d-flex justify-content-between mb-2">
<span><kbd>j</kbd> eller <kbd></kbd></span>
<span class="text-muted">Næste email</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span><kbd>k</kbd> eller <kbd></kbd></span>
<span class="text-muted">Forrige email</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span><kbd>Enter</kbd></span>
<span class="text-muted">Åbn email</span>
</div>
</div>
<div class="col-12">
<h6 class="text-muted">Handlinger</h6>
<div class="d-flex justify-content-between mb-2">
<span><kbd>e</kbd></span>
<span class="text-muted">Arkivér</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span><kbd>r</kbd></span>
<span class="text-muted">Genbehandl</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span><kbd>c</kbd></span>
<span class="text-muted">Skift klassificering</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span><kbd>x</kbd></span>
<span class="text-muted">Vælg/fravælg</span>
</div>
</div>
<div class="col-12">
<h6 class="text-muted">Andet</h6>
<div class="d-flex justify-content-between mb-2">
<span><kbd>/</kbd> eller <kbd>Cmd+K</kbd></span>
<span class="text-muted">Søg</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span><kbd>Esc</kbd></span>
<span class="text-muted">Luk/fravælg</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Attachment Preview Modal -->
<div class="modal fade" id="attachmentPreviewModal" tabindex="-1" data-bs-backdrop="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="attachmentPreviewTitle">Vedhæftning</h5>
<div class="ms-auto d-flex gap-2">
<a id="attachmentDownloadBtn" href="#" download class="btn btn-sm btn-primary">
<i class="bi bi-download me-1"></i>Download
</a>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body p-0" style="min-height: 70vh; max-height: 80vh; overflow: auto;">
<div id="attachmentPreviewContent" class="w-100 h-100 d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Workflow Management Modal -->
<div class="modal fade" id="workflowManagerModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-diagram-3 me-2"></i>Workflow Management
</h5>
<div class="d-flex gap-2">
<a href="/docs/WORKFLOW_SYSTEM_GUIDE.md" target="_blank" class="btn btn-sm btn-outline-primary" title="Åbn brugervejledning">
<i class="bi bi-book me-1"></i>Guide
</a>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body">
<!-- Tabs -->
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#workflowsTab">
<i class="bi bi-list-task me-1"></i>Workflows
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#actionsTab">
<i class="bi bi-box-seam me-1"></i>Actions
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#executionsTab">
<i class="bi bi-clock-history me-1"></i>History
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#statsTab">
<i class="bi bi-graph-up me-1"></i>Stats
</button>
</li>
</ul>
<div class="tab-content">
<!-- Workflows Tab -->
<div class="tab-pane fade show active" id="workflowsTab">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Aktive Workflows</h6>
<div class="btn-group btn-group-sm">
<button class="btn btn-primary" onclick="createNewWorkflow()">
<i class="bi bi-plus-circle me-1"></i>Ny Workflow
</button>
<button class="btn btn-outline-primary" onclick="showWorkflowTemplates()">
<i class="bi bi-file-earmark-text me-1"></i>Templates
</button>
<button class="btn btn-outline-secondary" onclick="importWorkflow()">
<i class="bi bi-upload me-1"></i>Import
</button>
</div>
</div>
<div id="workflowsList" class="list-group">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
<!-- Actions Tab -->
<div class="tab-pane fade" id="actionsTab">
<div class="mb-3 d-flex justify-content-between align-items-center">
<div>
<h6>Tilgængelige Workflow Actions</h6>
<p class="text-muted small mb-0">Disse actions kan bruges i workflow steps</p>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="showActionGuide()">
<i class="bi bi-question-circle me-1"></i>Quick Guide
</button>
</div>
<div id="actionsList" class="row g-3">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
<!-- Executions History Tab -->
<div class="tab-pane fade" id="executionsTab">
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" placeholder="Søg i execution history..." id="executionsSearch">
<select class="form-select" style="max-width: 200px;" id="executionsFilter">
<option value="">Alle status</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="running">Running</option>
</select>
</div>
</div>
<div id="executionsList" class="list-group">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
<!-- Stats Tab -->
<div class="tab-pane fade" id="statsTab">
<div id="workflowStats">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Workflow Editor Modal -->
<div class="modal fade" id="workflowEditorModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="workflowEditorTitle">Rediger Workflow</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="workflowForm">
<input type="hidden" id="workflowId">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Navn</label>
<input type="text" class="form-control" id="workflowName" required>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="workflowDescription" rows="2"></textarea>
</div>
<div class="col-md-6">
<label class="form-label">
Classification Trigger
<i class="bi bi-question-circle text-muted"
title="Workflow triggers når email får denne classification fra AI"
data-bs-toggle="tooltip"></i>
</label>
<select class="form-select" id="workflowTrigger" required>
<option value="invoice">Invoice</option>
<option value="freight_note">Freight Note</option>
<option value="time_confirmation">Time Confirmation</option>
<option value="bankruptcy">Bankruptcy</option>
<option value="spam">Spam</option>
<option value="general">General</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">
Confidence Threshold
<i class="bi bi-question-circle text-muted"
title="Workflow kører kun hvis AI confidence er højere end denne værdi (0.70 = 70%)"
data-bs-toggle="tooltip"></i>
</label>
<input type="number" class="form-control" id="workflowConfidence"
min="0" max="1" step="0.05" value="0.70" required>
<small class="text-muted">0.0 - 1.0 (anbefalet: 0.60-0.80)</small>
</div>
<div class="col-md-6">
<label class="form-label">
Prioritet
<i class="bi bi-question-circle text-muted"
title="Workflows med lavere prioritet køres først (10 før 100)"
data-bs-toggle="tooltip"></i>
</label>
<input type="number" class="form-control" id="workflowPriority" value="100">
<small class="text-muted">Lavere tal = højere prioritet (10 = meget høj, 100 = normal)</small>
</div>
<div class="col-md-6">
<label class="form-label">Status</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="workflowEnabled" checked>
<label class="form-check-label" for="workflowEnabled">Aktiveret</label>
</div>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Workflow Steps</label>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary" onclick="addWorkflowStep()">
<i class="bi bi-plus-circle me-1"></i>Tilføj Step
</button>
<button type="button" class="btn btn-outline-secondary" onclick="toggleJsonView()">
<i class="bi bi-code-square me-1"></i>JSON
</button>
</div>
</div>
<!-- Visual Step Builder -->
<div id="workflowStepsBuilder" class="border rounded p-3 bg-light">
<div id="stepsList" class="list-group">
<!-- Steps will be added here dynamically -->
</div>
<div id="emptyStepsMessage" class="text-center text-muted py-4">
<i class="bi bi-diagram-3 fs-1 d-block mb-2"></i>
Ingen steps endnu. Klik "Tilføj Step" for at starte.
</div>
</div>
<!-- JSON View (hidden by default) -->
<textarea class="form-control font-monospace d-none" id="workflowStepsJson" rows="10"></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-outline-info" onclick="testCurrentWorkflow()">
<i class="bi bi-play-circle me-1"></i>Test Workflow
</button>
<button type="button" class="btn btn-primary" onclick="saveWorkflow()">
<i class="bi bi-save me-1"></i>Gem
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
console.log('🚀 Email UI JavaScript loaded');
// State Management
let currentFilter = 'active'; // Default to active (unprocessed) emails
let currentEmailId = null;
let emails = [];
let selectedEmails = new Set();
let emailSearchTimeout = null;
let autoRefreshInterval = null;
let sagAssignmentOptions = { users: [], groups: [] };
let customerSearchHideTimeout = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('📧 Email UI: DOMContentLoaded fired');
console.log('Email list element:', document.getElementById('emailListBody'));
loadEmails();
loadStats();
setupEventListeners();
setupKeyboardShortcuts();
preloadSagAssignmentOptions();
startAutoRefresh();
});
// Setup Event Listeners
function setupEventListeners() {
document.getElementById('emailSearchInput').addEventListener('input', (e) => {
clearTimeout(emailSearchTimeout);
emailSearchTimeout = setTimeout(() => {
loadEmails(e.target.value);
}, 300);
});
}
// Keyboard Shortcuts
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
if (e.key === 'Escape') e.target.blur();
return;
}
switch(e.key) {
case 'j':
case 'ArrowDown':
e.preventDefault();
navigateEmails('next');
break;
case 'k':
case 'ArrowUp':
e.preventDefault();
navigateEmails('prev');
break;
case 'Enter':
if (currentEmailId) loadEmailDetail(currentEmailId);
break;
case 'e':
if (currentEmailId) archiveEmail();
break;
case 'r':
if (currentEmailId) reprocessEmail();
break;
case 'c':
if (currentEmailId) document.getElementById('classificationSelect')?.focus();
break;
case 'x':
if (currentEmailId) toggleEmailSelection(currentEmailId);
break;
case '/':
e.preventDefault();
document.getElementById('emailSearchInput').focus();
break;
case 'Escape':
clearSelection();
currentEmailId = null;
showEmptyState();
break;
case '?':
new bootstrap.Modal(document.getElementById('shortcutsModal')).show();
break;
}
});
}
function navigateEmails(direction) {
if (emails.length === 0) return;
let currentIndex = emails.findIndex(e => e.id === currentEmailId);
if (currentIndex === -1) {
currentIndex = 0;
} else if (direction === 'next' && currentIndex < emails.length - 1) {
currentIndex++;
} else if (direction === 'prev' && currentIndex > 0) {
currentIndex--;
} else {
return;
}
const email = emails[currentIndex];
selectEmail(email.id);
const emailElement = document.querySelector(`[data-email-id="${email.id}"]`);
if (emailElement) {
emailElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
let isLoadingEmails = false;
async function loadEmails(searchQuery = '') {
// Prevent duplicate calls
if (isLoadingEmails) {
console.log('⏭️ Already loading emails, skipping...');
return;
}
try {
isLoadingEmails = true;
// Show loading state
const tbody = document.getElementById('emailListBody');
if (tbody) {
tbody.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary" role="status"></div><p class="mt-2 text-muted">Indlæser emails...</p></div>';
}
let url = '/api/v1/emails?limit=100';
// Handle special filters
if (currentFilter === 'active') {
// Active queue includes both new and awaiting manual handling.
// We fetch list data and filter client-side because API status filter is single-value.
} else if (currentFilter === 'processed') {
url += '&status=processed';
} else if (currentFilter === 'awaiting_user_action') {
url += '&status=awaiting_user_action';
} else if (currentFilter !== 'all') {
// Classification filter
url += `&classification=${currentFilter}`;
}
if (searchQuery) {
url += `&q=${encodeURIComponent(searchQuery)}`;
}
console.log('Loading emails from:', url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
emails = await response.json();
if (currentFilter === 'active' && !searchQuery) {
emails = emails.filter((email) => ['new', 'awaiting_user_action'].includes(email.status || 'new'));
}
console.log('Loaded emails:', emails.length, 'items');
renderEmailList(emails);
} catch (error) {
console.error('Failed to load emails:', error);
showError('Kunne ikke indlæse emails: ' + error.message);
// Show error state in UI
const tbody = document.getElementById('emailListBody');
if (tbody) {
tbody.innerHTML = `
<div class="empty-state">
<i class="bi bi-exclamation-triangle text-danger"></i>
<p>Kunne ikke indlæse emails</p>
<small class="text-muted">${error.message}</small>
<button class="btn btn-sm btn-outline-primary mt-3" onclick="loadEmails()">
<i class="bi bi-arrow-clockwise me-1"></i>Prøv igen
</button>
</div>
`;
}
} finally {
isLoadingEmails = false;
}
}
function renderEmailList(emailList) {
const tbody = document.getElementById('emailListBody');
if (!tbody) {
console.error('❌ emailListBody element not found!');
return;
}
console.log('Rendering email list:', emailList ? emailList.length : 0, 'emails');
if (!emailList || emailList.length === 0) {
tbody.innerHTML = `
<div class="empty-state">
<i class="bi bi-inbox"></i>
<p>Ingen emails fundet</p>
<small class="text-muted">Filter: ${currentFilter}</small>
</div>
`;
return;
}
try {
tbody.innerHTML = emailList.map(email => {
const initials = getInitials(email.sender_name || email.sender_email);
const timeAgo = formatTimeAgo(email.received_date);
const preview = (email.body_text || '').substring(0, 80);
const unreadClass = email.is_read ? '' : 'unread';
const activeClass = email.id === currentEmailId ? 'active' : '';
const classification = email.classification || 'general';
return `
<div class="email-item ${unreadClass} ${activeClass}"
data-email-id="${email.id}"
onclick="selectEmail(${email.id})">
<input type="checkbox"
class="email-checkbox"
${selectedEmails.has(email.id) ? 'checked' : ''}
onclick="event.stopPropagation(); toggleEmailSelection(${email.id})">
<div class="sender-avatar">${initials}</div>
<div class="email-item-content">
<div class="email-item-header">
<div class="email-sender">${email.sender_name || email.sender_email}</div>
<div class="email-time">${timeAgo}</div>
</div>
<div class="email-subject">${escapeHtml(email.subject || '(Ingen emne)')}</div>
<div class="email-preview">${escapeHtml(preview)}</div>
<div class="email-meta">
${!email.is_read ? '<span class="unread-indicator"></span>' : ''}
<span class="classification-badge classification-${classification}">
${formatClassification(classification)}
</span>
${getStatusBadge(email)}
${email.confidence_score ? `<small class="text-muted">${Math.round(email.confidence_score * 100)}%</small>` : ''}
${email.has_attachments ? `<span class="text-muted ms-2"><i class="bi bi-paperclip"></i> ${email.attachment_count || ''}</span>` : ''}
</div>
</div>
</div>
`;
}).join('');
console.log('Email list rendered successfully');
} catch (error) {
console.error('Error rendering email list:', error);
tbody.innerHTML = `
<div class="empty-state">
<i class="bi bi-exclamation-triangle text-danger"></i>
<p>Fejl ved visning af emails</p>
<small>${error.message}</small>
</div>
`;
}
}
async function selectEmail(emailId) {
currentEmailId = emailId;
document.querySelectorAll('.email-item').forEach(item => {
item.classList.toggle('active', item.dataset.emailId == emailId);
});
await loadEmailDetail(emailId);
}
async function loadEmailDetail(emailId) {
try {
console.log('Loading email detail for ID:', emailId);
const response = await fetch(`/api/v1/emails/${emailId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const email = await response.json();
console.log('Email detail loaded:', email);
renderEmailDetail(email);
renderEmailAnalysis(email);
if (!email.is_read) {
await markAsRead(emailId);
}
} catch (error) {
console.error('Failed to load email detail:', error);
const errorMsg = error?.message || String(error) || 'Ukendt fejl';
alert('Kunne ikke indlæse email detaljer: ' + errorMsg);
showEmptyState();
}
}
function getClassificationActions(email) {
const actions = [];
switch(email.classification) {
case 'invoice':
actions.push(`
<button class="btn btn-primary w-100" onclick="createSupplierInvoice(${email.id})" title="Opret leverandørfaktura">
<i class="bi bi-receipt me-2"></i>Opret Leverandørfaktura
</button>
`);
break;
case 'order_confirmation':
actions.push(`
<button class="btn btn-success w-100" onclick="createOrder(${email.id})" title="Opret ordre">
<i class="bi bi-cart-check me-2"></i>Opret Ordre
</button>
`);
break;
case 'freight_note':
actions.push(`
<button class="btn btn-info w-100" onclick="createFreightNote(${email.id})" title="Registrer fragt">
<i class="bi bi-truck me-2"></i>Registrer Fragt
</button>
`);
break;
case 'time_confirmation':
actions.push(`
<button class="btn btn-warning w-100" onclick="createTimeEntry(${email.id})" title="Registrer tid">
<i class="bi bi-clock me-2"></i>Registrer Tid
</button>
`);
break;
case 'case_notification':
actions.push(`
<button class="btn btn-secondary w-100" onclick="createCase(${email.id})" title="Opret sag">
<i class="bi bi-folder me-2"></i>Opret Sag
</button>
`);
break;
case 'customer_email':
actions.push(`
<button class="btn btn-primary w-100" onclick="linkToCustomer(${email.id})" title="Link til kunde">
<i class="bi bi-person-lines-fill me-2"></i>Link til Kunde
</button>
`);
break;
}
return actions.join('');
}
function renderEmailDetail(email) {
const pane = document.getElementById('emailContentPane');
const initials = getInitials(email.sender_name || email.sender_email);
const timestamp = formatDateTime(email.received_date);
const attachmentsHtml = email.attachments && email.attachments.length > 0 ? `
<div class="email-attachments">
<h6 class="mb-3"><i class="bi bi-paperclip me-2"></i>Vedhæftninger (${email.attachments.length})</h6>
${email.attachments.map(att => {
const canPreview = canPreviewFile(att.content_type);
return `
<div class="attachment-item d-flex align-items-center">
<i class="bi bi-file-earmark-${getFileIcon(att.content_type)} me-2"></i>
<span class="flex-grow-1">${att.filename}</span>
<small class="text-muted me-2">${formatFileSize(att.size_bytes || att.file_size)}</small>
${canPreview ? `
<button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')"
class="btn btn-sm btn-outline-primary me-1" title="Se">
<i class="bi bi-eye"></i>
</button>
` : ''}
<a href="/api/v1/emails/${email.id}/attachments/${att.id}"
class="btn btn-sm btn-outline-secondary"
download="${att.filename}"
title="Download">
<i class="bi bi-download"></i>
</a>
</div>
`;
}).join('')}
</div>
` : '';
pane.innerHTML = `
<div class="email-content-header">
<h1 class="email-content-subject">${escapeHtml(email.subject || '(Ingen emne)')}</h1>
<div class="email-content-meta">
<div class="sender-info">
<div class="sender-avatar" style="width: 48px; height: 48px; font-size: 1rem;">${initials}</div>
<div class="sender-details">
<div class="sender-name">${email.sender_name || 'Ukendt afsender'}</div>
<div class="sender-email">${email.sender_email}</div>
</div>
</div>
<div class="email-timestamp ms-auto">
<i class="bi bi-clock me-1"></i>${timestamp}
</div>
</div>
</div>
<div class="email-actions d-flex justify-content-between align-items-center">
<div class="d-flex gap-2">
<button class="btn btn-sm btn-light border" onclick="archiveEmail()" title="Arkivér (e)">
<i class="bi bi-archive"></i>
</button>
<button class="btn btn-sm btn-light border" onclick="markAsSpam()" title="Marker som spam">
<i class="bi bi-exclamation-triangle"></i>
</button>
<button class="btn btn-sm btn-light border" onclick="reprocessEmail()" title="Genbehandl (r)">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows">
<i class="bi bi-diagram-3 me-1"></i>Workflows
</button>
<button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
${email.attachments && email.attachments.length > 0 ? `
<div class="d-flex align-items-center gap-2">
<span class="text-muted"><i class="bi bi-paperclip me-1"></i>${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''}</span>
${email.attachments.map(att => {
const canPreview = canPreviewFile(att.content_type);
return `
${canPreview ? `
<button onclick="previewAttachment(${email.id}, ${att.id}, '${escapeHtml(att.filename)}', '${att.content_type}')"
class="btn btn-sm btn-outline-primary attachment-chip" title="Se ${att.filename}">
<i class="bi bi-eye me-1"></i>${att.filename}
</button>
` : `
<a href="/api/v1/emails/${email.id}/attachments/${att.id}"
class="btn btn-sm btn-outline-secondary attachment-chip"
download="${att.filename}"
title="Download ${att.filename}">
<i class="bi bi-download me-1"></i>${att.filename}
</a>
`}
`;
}).join('')}
</div>
` : ''}
</div>
<div class="email-body">
${email.body_html ? `<div class="email-html-body"></div>` :
`<pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(email.body_text || 'Ingen indhold')}</pre>`}
</div>
`;
// If HTML, inject it as innerHTML after rendering
if (email.body_html) {
const htmlDiv = pane.querySelector('.email-html-body');
if (htmlDiv) htmlDiv.innerHTML = email.body_html;
}
}
function renderEmailAnalysis(email) {
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
if (!aiAnalysisTab) {
console.error('aiAnalysisTab element not found in DOM');
return;
}
const classification = email.classification || 'general';
const confidence = email.confidence_score || 0;
const primaryType = suggestPrimaryType(email);
const secondaryLabel = suggestSecondaryLabel(email);
const selectedCustomerName = email.customer_name || '';
const userOptions = (sagAssignmentOptions.users || []).map((user) =>
`<option value="${user.id}">${escapeHtml(user.name)}</option>`
).join('');
const groupOptions = (sagAssignmentOptions.groups || []).map((group) =>
`<option value="${group.id}">${escapeHtml(group.name)}</option>`
).join('');
aiAnalysisTab.innerHTML = `
<div class="analysis-card">
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
<div class="quick-action-row mb-3">
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
<i class="bi bi-arrow-repeat me-1"></i>Ret Type
</button>
<button class="btn btn-sm btn-outline-danger" onclick="markAsSpam()">
<i class="bi bi-slash-circle me-1"></i>Spam
</button>
</div>
<div class="suggestion-grid">
<div class="suggestion-field">
<label for="casePrimaryType">Primær Type</label>
<select id="casePrimaryType">
<option value="support" ${primaryType === 'support' ? 'selected' : ''}>Support</option>
<option value="bogholderi" ${primaryType === 'bogholderi' ? 'selected' : ''}>Bogholderi</option>
<option value="leverandor" ${primaryType === 'leverandor' ? 'selected' : ''}>Leverandør</option>
<option value="helhedsopgave" ${primaryType === 'helhedsopgave' ? 'selected' : ''}>Helhedsopgave</option>
<option value="andet" ${primaryType === 'andet' ? 'selected' : ''}>Andet</option>
</select>
</div>
<div class="suggestion-field">
<label for="caseSecondaryLabel">Sekundær Label</label>
<input id="caseSecondaryLabel" type="text" maxlength="60" value="${escapeHtml(secondaryLabel)}" placeholder="fx Fakturaspørgsmål">
</div>
<div class="suggestion-field full">
<label for="caseCustomerSearch">Kunde</label>
<div class="customer-search-wrap">
<input id="caseCustomerSearch" value="${escapeHtml(selectedCustomerName)}" placeholder="Søg kunde, CVR, domæne..." oninput="searchCustomersForCurrentEmail(this.value)" onfocus="searchCustomersForCurrentEmail(this.value)" onblur="hideCustomerSearchResultsDelayed()">
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
</div>
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
</div>
<div class="suggestion-field">
<label for="caseAssignee">Ansvarlig Bruger</label>
<select id="caseAssignee">
<option value="">Ingen bruger</option>
${userOptions}
</select>
</div>
<div class="suggestion-field">
<label for="caseGroup">Gruppe</label>
<select id="caseGroup">
<option value="">Ingen gruppe</option>
${groupOptions}
</select>
</div>
<div class="suggestion-field">
<label for="caseStartDate">Startdato</label>
<input id="caseStartDate" type="date" value="${todayAsDateString()}">
</div>
<div class="suggestion-field">
<label for="caseDeadline">Deadline</label>
<input id="caseDeadline" type="date" value="">
</div>
<div class="suggestion-field">
<label for="casePriority">Prioritet</label>
<select id="casePriority">
<option value="low">Lav</option>
<option value="normal" selected>Normal</option>
<option value="high">Høj</option>
<option value="urgent">Akut</option>
</select>
</div>
<div class="suggestion-field full">
<label for="caseTitle">Titel</label>
<input id="caseTitle" type="text" value="${escapeHtml((email.subject || `E-mail fra ${email.sender_email || 'ukendt'}`).slice(0, 250))}">
</div>
</div>
<div class="quick-action-row mt-3">
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
</button>
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
<i class="bi bi-link-45deg me-1"></i>Tilknyt Eksisterende Sag
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('support')">Support</button>
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('leverandor')">Leverandør</button>
</div>
<div id="linkExistingPanel" class="mt-3" style="display:none;">
<div class="suggestion-field full">
<label for="existingSagSearch">Søg Sag</label>
<input id="existingSagSearch" list="existingSagResults" placeholder="Søg på titel eller ID..." oninput="searchSagerForCurrentEmail(this.value)">
<datalist id="existingSagResults"></datalist>
<input id="existingSagId" type="hidden" value="">
</div>
<div class="suggestion-field full mt-2">
<label for="existingSagRelationType">Tilføj mail som</label>
<select id="existingSagRelationType">
<option value="kommentar">Kommentar</option>
<option value="intern_note">Intern note</option>
<option value="kundeopdatering">Kundeopdatering</option>
</select>
</div>
<button class="btn btn-sm btn-primary mt-2" onclick="linkCurrentEmailToExistingSag()">
<i class="bi bi-link me-1"></i>Tilknyt Sag
</button>
</div>
</div>
<div class="analysis-card">
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
<div class="confidence-meter">
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${confidence * 100}%"></div>
</div>
<div class="confidence-label">
<span>Sikkerhed</span>
<span><strong>${Math.round(confidence * 100)}%</strong></span>
</div>
</div>
<select class="classification-select" id="classificationSelect" onchange="updateClassification()">
<option value="invoice" ${classification === 'invoice' ? 'selected' : ''}>📄 Faktura</option>
<option value="order_confirmation" ${classification === 'order_confirmation' ? 'selected' : ''}>📦 Ordrebekræftelse</option>
<option value="freight_note" ${classification === 'freight_note' ? 'selected' : ''}>🚚 Fragtnote</option>
<option value="time_confirmation" ${classification === 'time_confirmation' ? 'selected' : ''}>⏰ Tidsregistrering</option>
<option value="case_notification" ${classification === 'case_notification' ? 'selected' : ''}>📋 Sagsnotifikation</option>
<option value="recording" ${classification === 'recording' ? 'selected' : ''}>🎤 Optagelse</option>
<option value="bankruptcy" ${classification === 'bankruptcy' ? 'selected' : ''}>⚠️ Konkurs</option>
<option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
</select>
<button class="btn btn-sm btn-primary w-100" onclick="saveClassification()">
<i class="bi bi-check-lg me-2"></i>Gem Klassificering
</button>
</div>
`;
const statusBadge = document.getElementById('emailActionStatus');
if (statusBadge) {
statusBadge.textContent = email.status || 'new';
}
}
function todayAsDateString() {
return new Date().toISOString().split('T')[0];
}
function suggestPrimaryType(email) {
const classification = (email.classification || '').toLowerCase();
if (classification === 'invoice') return 'bogholderi';
if (classification === 'time_confirmation') return 'support';
if (classification === 'case_notification') return 'support';
if (email.supplier_id) return 'leverandor';
return 'support';
}
function suggestSecondaryLabel(email) {
const classification = (email.classification || '').toLowerCase();
const mapping = {
invoice: 'Fakturasporgsmal',
time_confirmation: 'Tidsbekraftelse',
case_notification: 'Sag opdatering',
order_confirmation: 'Ordre bekraftelse',
freight_note: 'Fragt opdatering',
general: 'Generel henvendelse'
};
return mapping[classification] || 'Kunde henvendelse';
}
async function preloadSagAssignmentOptions() {
try {
const response = await fetch('/api/v1/emails/sag-options');
if (!response.ok) return;
sagAssignmentOptions = await response.json();
} catch (error) {
console.warn('Could not preload sag assignment options:', error);
}
}
function focusTypeEditor() {
document.getElementById('casePrimaryType')?.focus();
}
function setPrimaryType(value) {
const el = document.getElementById('casePrimaryType');
if (el) el.value = value;
}
function toggleLinkExistingPanel() {
const panel = document.getElementById('linkExistingPanel');
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
function getSelectedIdFromDatalist(inputId, datalistId) {
const input = document.getElementById(inputId);
const datalist = document.getElementById(datalistId);
if (!input || !datalist) return null;
const option = [...datalist.options].find((opt) => opt.value === input.value);
return option ? option.dataset.id : null;
}
function hideCustomerSearchResultsDelayed() {
clearTimeout(customerSearchHideTimeout);
customerSearchHideTimeout = setTimeout(() => {
const resultsEl = document.getElementById('caseCustomerResults');
if (resultsEl) resultsEl.style.display = 'none';
}, 180);
}
function renderCustomerSearchResults(customers) {
const resultsEl = document.getElementById('caseCustomerResults');
if (!resultsEl) return;
if (!customers || customers.length === 0) {
resultsEl.innerHTML = '<div class="customer-search-item"><div class="customer-search-meta">Ingen kunder fundet</div></div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = customers.map((customer) => {
const metaParts = [];
if (customer.cvr_number) metaParts.push(`CVR ${escapeHtml(customer.cvr_number)}`);
if (customer.email_domain) metaParts.push(escapeHtml(customer.email_domain));
if (customer.email) metaParts.push(escapeHtml(customer.email));
const meta = metaParts.join(' • ');
return `
<div class="customer-search-item" onmousedown="selectCustomerForCurrentEmail(${customer.id}, '${escapeHtml(customer.name).replace(/'/g, "\\'")}')">
<div class="customer-search-name">${escapeHtml(customer.name)} <span class="text-muted">#${customer.id}</span></div>
<div class="customer-search-meta">${meta || 'Ingen ekstra data'}</div>
</div>
`;
}).join('');
resultsEl.style.display = 'block';
}
function selectCustomerForCurrentEmail(customerId, customerName) {
const input = document.getElementById('caseCustomerSearch');
const hidden = document.getElementById('caseCustomerId');
const resultsEl = document.getElementById('caseCustomerResults');
if (input) input.value = customerName;
if (hidden) hidden.value = String(customerId);
if (resultsEl) resultsEl.style.display = 'none';
}
async function searchCustomersForCurrentEmail(query) {
const hidden = document.getElementById('caseCustomerId');
if (hidden) hidden.value = '';
if (!query || query.length < 2) {
const resultsEl = document.getElementById('caseCustomerResults');
if (resultsEl) resultsEl.style.display = 'none';
return;
}
try {
const response = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
if (!response.ok) return;
const customers = await response.json();
renderCustomerSearchResults(customers);
} catch (error) {
console.warn('Customer search failed:', error);
}
}
async function searchSagerForCurrentEmail(query) {
if (!query || query.length < 2) return;
try {
const response = await fetch(`/api/v1/emails/search-sager?q=${encodeURIComponent(query)}`);
if (!response.ok) return;
const sager = await response.json();
const datalist = document.getElementById('existingSagResults');
if (!datalist) return;
datalist.innerHTML = sager.map((sag) => {
const display = `SAG-${sag.id}: ${sag.titel || '(uden titel)'}`;
return `<option value="${escapeHtml(display)}" data-id="${sag.id}"></option>`;
}).join('');
} catch (error) {
console.warn('SAG search failed:', error);
}
}
function confirmSuggestion() {
createCaseFromCurrentForm();
}
function getCaseFormPayload() {
const customerIdHidden = document.getElementById('caseCustomerId')?.value;
const resolvedCustomerId = customerIdHidden || null;
return {
titel: document.getElementById('caseTitle')?.value?.trim() || null,
customer_id: resolvedCustomerId ? Number(resolvedCustomerId) : null,
case_type: document.getElementById('casePrimaryType')?.value || 'support',
secondary_label: document.getElementById('caseSecondaryLabel')?.value?.trim() || null,
start_date: document.getElementById('caseStartDate')?.value || null,
deadline: document.getElementById('caseDeadline')?.value || null,
priority: document.getElementById('casePriority')?.value || 'normal',
ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null,
assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null,
relation_type: 'kommentar'
};
}
async function createCaseFromCurrentForm() {
if (!currentEmailId) return;
const payload = getCaseFormPayload();
if (!payload.customer_id) {
showError('Vælg kunde før sag-oprettelse');
return;
}
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Sag-oprettelse fejlede');
}
const result = await response.json();
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
loadEmails();
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunne ikke oprette sag');
}
}
async function linkCurrentEmailToExistingSag() {
if (!currentEmailId) return;
const selectedSagId = getSelectedIdFromDatalist('existingSagSearch', 'existingSagResults') || document.getElementById('existingSagId')?.value;
if (!selectedSagId) {
showError('Vælg en eksisterende sag først');
return;
}
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/link-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: Number(selectedSagId),
relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar'
})
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Tilknytning fejlede');
}
showSuccess(`E-mail knyttet til SAG-${selectedSagId}`);
loadEmails();
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunne ikke knytte e-mail til sag');
}
}
function showEmptyState() {
document.getElementById('emailContentPane').innerHTML = `
<div class="empty-state">
<i class="bi bi-envelope"></i>
<p>Vælg en email for at se indholdet</p>
<small class="text-muted">Brug ↑↓ eller j/k til at navigere</small>
</div>
`;
// Opdater kun AI Analysis tab - bevar tab struktur
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
if (aiAnalysisTab) {
aiAnalysisTab.innerHTML = `
<div class="analysis-card">
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
<p class="text-muted small mb-3">Vælg en email for at se analyse</p>
</div>
`;
}
// Reset activity log tab
const activityLogTab = document.getElementById('emailActivityTimeline');
if (activityLogTab) {
activityLogTab.innerHTML = '<p class="text-muted small">Vælg en email for at se aktivitet</p>';
}
}
function setFilter(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-pill').forEach(pill => {
pill.classList.toggle('active', pill.dataset.filter === filter);
});
loadEmails();
}
async function loadStats() {
try {
const response = await fetch('/api/v1/emails/stats/summary');
const stats = await response.json();
const newCount = stats.new_emails || 0;
const awaitingCount = stats.awaiting_user_action || 0;
const activeCount = newCount + awaitingCount;
document.getElementById('countActive').textContent = activeCount;
document.getElementById('countAll').textContent = stats.total_emails || 0;
document.getElementById('countProcessed').textContent = stats.processed_emails || 0;
document.getElementById('countAwaiting').textContent = awaitingCount;
document.getElementById('countInvoice').textContent = stats.invoices || 0;
document.getElementById('countOrder').textContent = 0;
document.getElementById('countFreight').textContent = 0;
document.getElementById('countTime').textContent = stats.time_confirmations || 0;
document.getElementById('countCase').textContent = 0;
document.getElementById('countNewsletter').textContent = stats.newsletters || 0;
document.getElementById('countGeneral').textContent = stats.total_emails - stats.invoices - stats.time_confirmations - (stats.newsletters || 0) || 0;
document.getElementById('countSpam').textContent = stats.spam_emails || 0;
} catch (error) {
console.error('Failed to load stats:', error);
}
}
async function markAsRead(emailId) {
try {
await fetch(`/api/v1/emails/${emailId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_read: true })
});
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
// ========== Email Activity Log Functions ==========
async function loadEmailActivityLog() {
if (!currentEmailId) {
document.getElementById('emailActivityTimeline').innerHTML =
'<p class="text-muted small">Vælg en email for at se aktivitet</p>';
return;
}
try {
document.getElementById('emailActivityTimeline').innerHTML = `
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
`;
const response = await fetch(`/api/v1/emails/${currentEmailId}/activity`);
const activities = await response.json();
if (activities.length === 0) {
document.getElementById('emailActivityTimeline').innerHTML =
'<p class="text-muted small">Ingen aktivitet endnu</p>';
return;
}
// Render timeline
const timeline = activities.map(activity => {
const icon = getActivityIcon(activity.event_type);
const color = getActivityColor(activity.event_category);
const time = formatDateTime(activity.created_at);
return `
<div class="activity-item">
<div class="activity-icon bg-${color}">
<i class="bi bi-${icon}"></i>
</div>
<div class="activity-content">
<div class="activity-header">
<strong class="activity-type">${formatEventType(activity.event_type)}</strong>
<span class="activity-time text-muted small">${time}</span>
</div>
<div class="activity-description">${activity.description}</div>
${activity.metadata ? `
<details class="activity-metadata mt-2">
<summary class="small text-muted" style="cursor: pointer;">Detaljer</summary>
<pre class="small bg-light p-2 mt-1 rounded"><code>${JSON.stringify(activity.metadata, null, 2)}</code></pre>
</details>
` : ''}
${activity.user_name ? `
<div class="activity-user text-muted small mt-1">
<i class="bi bi-person"></i> ${activity.user_name}
</div>
` : ''}
</div>
</div>
`;
}).join('');
document.getElementById('emailActivityTimeline').innerHTML = timeline;
} catch (error) {
console.error('Failed to load activity log:', error);
document.getElementById('emailActivityTimeline').innerHTML =
'<div class="alert alert-danger small">Kunne ikke indlæse aktivitet</div>';
}
}
function getActivityIcon(eventType) {
const icons = {
'fetched': 'download',
'classified': 'tag',
'workflow_executed': 'diagram-3',
'rule_matched': 'check-circle',
'status_changed': 'arrow-left-right',
'read': 'eye',
'attachment_downloaded': 'paperclip',
'linked': 'link-45deg',
'invoice_extracted': 'receipt',
'error': 'exclamation-triangle'
};
return icons[eventType] || 'circle';
}
function getActivityColor(category) {
const colors = {
'system': 'primary',
'user': 'success',
'workflow': 'info',
'rule': 'warning',
'integration': 'secondary'
};
return colors[category] || 'secondary';
}
function formatEventType(eventType) {
const labels = {
'fetched': 'Hentet',
'classified': 'Klassificeret',
'workflow_executed': 'Workflow Kørt',
'rule_matched': 'Regel Matchet',
'status_changed': 'Status Ændret',
'read': 'Læst',
'attachment_downloaded': 'Attachment Downloaded',
'linked': 'Linket',
'invoice_extracted': 'Faktura Ekstraheret',
'error': 'Fejl'
};
return labels[eventType] || eventType;
}
async function archiveEmail() {
if (!currentEmailId) return;
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}?status=archived`, {
method: 'PUT'
});
if (!response.ok) throw new Error('Archive failed');
showSuccess('Email arkiveret');
currentEmailId = null;
document.getElementById('emailContentPane').innerHTML = '<div class="empty-state"><i class="bi bi-inbox"></i><p>Vælg en email for at se indholdet</p></div>';
document.getElementById('emailAnalysisSidebar').innerHTML = '';
loadEmails();
} catch (error) {
showError('Kunne ikke arkivere email');
}
}
async function markAsSpam() {
if (!currentEmailId) return;
if (!confirm('Marker denne email som spam?')) return;
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/classify`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classification: 'spam', confidence: 1.0 })
});
if (!response.ok) throw new Error('Classify failed');
showSuccess('Markeret som spam');
currentEmailId = null;
document.getElementById('emailContentPane').innerHTML = '<div class="empty-state"><i class="bi bi-inbox"></i><p>Vælg en email for at se indholdet</p></div>';
document.getElementById('emailAnalysisSidebar').innerHTML = '';
loadEmails();
} catch (error) {
showError('Kunne ikke markere som spam');
}
}
async function reprocessEmail() {
if (!currentEmailId) return;
try {
const btn = event.target.closest('button');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
const response = await fetch(`/api/v1/emails/${currentEmailId}/reprocess`, {
method: 'POST'
});
if (!response.ok) throw new Error('Reprocess failed');
const result = await response.json();
showSuccess(`Genbehandlet: ${result.classification} (${Math.round(result.confidence * 100)}%)`);
await loadEmailDetail(currentEmailId);
loadEmails();
} catch (error) {
showError('Kunne ikke genbehandle email');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
}
}
}
async function deleteEmail() {
if (!currentEmailId) return;
if (!confirm('Slet denne email permanent?')) return;
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
showSuccess('Email slettet');
currentEmailId = null;
showEmptyState();
loadEmails();
} catch (error) {
showError('Kunne ikke slette email');
}
}
// Classification Action Handlers
async function createSupplierInvoice(emailId) {
try {
console.log('📧 Behandler email...');
// Get email data with attachments
const response = await fetch(`/api/v1/emails/${emailId}`);
if (!response.ok) throw new Error('Failed to fetch email');
const email = await response.json();
console.log('📧 Email data:', email);
// Process attachments
if (!email.attachments || email.attachments.length === 0) {
showError('Emailen har ingen vedhæftninger');
return;
}
// Find PDF attachments
const pdfAttachments = email.attachments.filter(att =>
att.filename && att.filename.toLowerCase().endsWith('.pdf')
);
if (pdfAttachments.length === 0) {
showError('Ingen PDF vedhæftninger fundet i emailen');
return;
}
console.log(`📎 Behandler ${pdfAttachments.length} PDF vedhæftning${pdfAttachments.length > 1 ? 'er' : ''}...`);
let successCount = 0;
let errorCount = 0;
// Process each PDF attachment
for (const attachment of pdfAttachments) {
try {
console.log(`⬇️ Downloading: ${attachment.filename}`);
// Download attachment
const attachmentResponse = await fetch(`/api/v1/emails/${emailId}/attachments/${attachment.id}`);
if (!attachmentResponse.ok) {
console.error(`Failed to download ${attachment.filename}`);
errorCount++;
continue;
}
const blob = await attachmentResponse.blob();
const file = new File([blob], attachment.filename, { type: 'application/pdf' });
// Upload to supplier invoices
console.log(`⬆️ Uploading: ${attachment.filename}`);
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch('/api/v1/supplier-invoices/upload', {
method: 'POST',
body: formData
});
if (uploadResponse.ok) {
const result = await uploadResponse.json();
console.log('✅ Upload successful:', result);
successCount++;
} else {
const errorData = await uploadResponse.json();
console.error('Upload failed:', errorData);
showError(`${attachment.filename}: ${errorData.detail || 'Upload fejlede'}`);
errorCount++;
}
} catch (error) {
console.error(`Error processing ${attachment.filename}:`, error);
errorCount++;
}
}
// Show result
if (successCount > 0) {
showSuccess(`${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}`);
// Mark email as processed and move to Processed folder
try {
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
method: 'POST'
});
if (markResponse.ok) {
console.log('✅ Email marked as processed and moved to Processed folder');
// Reload email list to reflect changes
loadEmails();
} else {
console.warn('⚠️ Could not mark email as processed');
}
} catch (e) {
console.error('Error marking email as processed:', e);
}
// Ask if user wants to go to supplier invoices page
if (confirm(`${successCount} faktura${successCount > 1 ? 'er' : ''} er uploadet og behandlet.\n\nVil du gå til Leverandørfakturaer for at gennemse?`)) {
window.location.href = '/billing/supplier-invoices';
}
} else {
showError('❌ Ingen fakturaer kunne uploades');
}
} catch (error) {
console.error('Error creating supplier invoice:', error);
showError('Kunne ikke behandle email: ' + error.message);
}
}
async function createOrder(emailId) {
showError('Ordre-modul er ikke implementeret endnu');
}
async function createFreightNote(emailId) {
showError('Fragt-modul er ikke implementeret endnu');
}
async function createTimeEntry(emailId) {
showError('Tidsregistrering-modul er ikke implementeret endnu');
}
async function createCase(emailId) {
if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
setPrimaryType('support');
focusTypeEditor();
showInfo('Sagsforslag klar. Udfyld felter og klik Opret Ny Sag.');
}
async function linkToCustomer(emailId) {
if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
document.getElementById('caseCustomerSearch')?.focus();
showInfo('Søg og vælg kunde i forslagspanelet.');
}
// ─── Quick Create Customer ────────────────────────────────────────────────
function quickCreateCustomer(emailId, senderName, senderEmail) {
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
document.getElementById('qcEmailId').value = emailId;
document.getElementById('qcCustomerName').value = senderName || '';
document.getElementById('qcCustomerEmail').value = senderEmail || '';
document.getElementById('qcCustomerDomain').value = senderDomain;
document.getElementById('qcCustomerPhone').value = '';
document.getElementById('qcCustomerStatus').textContent = '';
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
modal.show();
}
async function submitQuickCustomer() {
const emailId = document.getElementById('qcEmailId').value;
const name = document.getElementById('qcCustomerName').value.trim();
const email = document.getElementById('qcCustomerEmail').value.trim();
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
const phone = document.getElementById('qcCustomerPhone').value.trim();
const statusEl = document.getElementById('qcCustomerStatus');
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
try {
// Create customer
const custResp = await fetch('/api/v1/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email: email || null,
email_domain: domain || null,
phone: phone || null
})
});
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
const customer = await custResp.json();
// Link email
await fetch(`/api/v1/emails/${emailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customer.id })
});
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
showSuccess(`Kunde "${name}" oprettet og linket`);
loadEmailDetail(parseInt(emailId));
} catch (e) {
statusEl.className = 'text-danger small';
statusEl.textContent = e.message;
}
}
// ─── Quick Create Vendor ──────────────────────────────────────────────────
async function quickCreateVendor(emailId, senderName, senderEmail) {
// Reset form
document.getElementById('qvEmailId').value = emailId;
document.getElementById('qvVendorName').value = senderName || '';
document.getElementById('qvVendorEmail').value = senderEmail || '';
document.getElementById('qvVendorCVR').value = '';
document.getElementById('qvVendorPhone').value = '';
document.getElementById('qvVendorAddress').value = '';
document.getElementById('qvVendorDomain').value = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1] : '';
document.getElementById('qvVendorStatus').textContent = '';
document.getElementById('qvAiStatus').innerHTML = '<span class="text-muted small"><i class="bi bi-hourglass-split me-1"></i>Henter info fra email/bilag…</span>';
const modal = new bootstrap.Modal(document.getElementById('quickCreateVendorModal'));
modal.show();
// Attempt AI extraction in background
try {
const resp = await fetch(`/api/v1/emails/${emailId}/extract-vendor-suggestion`, { method: 'POST' });
if (resp.ok) {
const s = await resp.json();
// ── Hurtig genvej: leverandøren kendes allerede (vendor_id fra PDF) ──
if (s.vendor_id && (s.source === 'pdf_extraction' || s.match_score >= 80)) {
bootstrap.Modal.getInstance(document.getElementById('quickCreateVendorModal')).hide();
// Auto-link direkte
const linkResp = await fetch(`/api/v1/emails/${emailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: s.vendor_id })
});
if (linkResp.ok) {
const scoreLabel = s.match_score >= 100 ? 'CVR-match' : s.match_score >= 80 ? 'domæne-match' : 'navne-match';
showSuccess(`Leverandør "${s.name || s.cvr_number}" auto-linket (${scoreLabel})`);
loadEmailDetail(parseInt(emailId));
} else {
showError('Auto-link fejlede — åbner formularen');
modal.show();
}
return;
}
if (s.name && s.name !== senderName) document.getElementById('qvVendorName').value = s.name;
if (s.cvr_number) document.getElementById('qvVendorCVR').value = s.cvr_number;
if (s.phone) document.getElementById('qvVendorPhone').value = s.phone;
if (s.address) document.getElementById('qvVendorAddress').value = s.address;
if (s.domain) document.getElementById('qvVendorDomain').value = s.domain;
if (s.email && !document.getElementById('qvVendorEmail').value)
document.getElementById('qvVendorEmail').value = s.email;
const srcLabel = s.source === 'pdf_extraction' ? '📄 PDF-faktura'
: s.source === 'ai' ? '🤖 AI'
: '🔍 Regex';
document.getElementById('qvAiStatus').innerHTML =
`<span class="text-success small"><i class="bi bi-check-circle me-1"></i>${srcLabel} felter preudfyldt</span>`;
} else {
document.getElementById('qvAiStatus').innerHTML =
'<span class="text-muted small"><i class="bi bi-info-circle me-1"></i>Ingen automatisk data fundet</span>';
}
} catch (e) {
document.getElementById('qvAiStatus').innerHTML =
'<span class="text-muted small"><i class="bi bi-info-circle me-1"></i>Udfyld manuelt</span>';
}
}
async function submitQuickVendor() {
const emailId = document.getElementById('qvEmailId').value;
const name = document.getElementById('qvVendorName').value.trim();
const email = document.getElementById('qvVendorEmail').value.trim();
const cvr = document.getElementById('qvVendorCVR').value.trim();
const phone = document.getElementById('qvVendorPhone').value.trim();
const address = document.getElementById('qvVendorAddress').value.trim();
const domain = document.getElementById('qvVendorDomain').value.trim();
const statusEl = document.getElementById('qvVendorStatus');
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
try {
// Create vendor
const vendResp = await fetch('/api/v1/vendors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
email: email || null,
cvr_number: cvr || null,
phone: phone || null,
address: address || null,
domain: domain || null
})
});
if (!vendResp.ok) throw new Error((await vendResp.json()).detail || 'Opret fejlede');
const vendor = await vendResp.json();
// Link email
await fetch(`/api/v1/emails/${emailId}/link`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: vendor.id })
});
bootstrap.Modal.getInstance(document.getElementById('quickCreateVendorModal')).hide();
showSuccess(`Leverandør "${name}" oprettet og linket`);
loadEmailDetail(parseInt(emailId));
} catch (e) {
statusEl.className = 'text-danger small';
statusEl.textContent = e.message;
}
}
async function saveClassification() {
if (!currentEmailId) return;
const classification = document.getElementById('classificationSelect').value;
try {
await fetch(`/api/v1/emails/${currentEmailId}/classify`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
classification: classification,
confidence: 0.9
})
});
showSuccess('Klassificering gemt');
loadEmails();
loadEmailDetail(currentEmailId);
} catch (error) {
showError('Kunne ikke gemme klassificering');
}
}
async function manualProcessEmails() {
const btn = document.getElementById('processBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Henter...';
try {
const response = await fetch('/api/v1/emails/process', {
method: 'POST'
});
const result = await response.json();
showSuccess(`Hentet ${result.stats.fetched} nye emails`);
loadEmails();
loadStats();
} catch (error) {
showError('Kunne ikke hente emails');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-2"></i>Hent Nye';
}
}
function toggleEmailSelection(emailId) {
if (selectedEmails.has(emailId)) {
selectedEmails.delete(emailId);
} else {
selectedEmails.add(emailId);
}
updateBulkToolbar();
renderEmailList(emails);
}
function toggleSelectAll() {
const checkbox = document.getElementById('selectAllCheckbox');
if (checkbox.checked) {
emails.forEach(email => selectedEmails.add(email.id));
} else {
selectedEmails.clear();
}
updateBulkToolbar();
renderEmailList(emails);
}
function clearSelection() {
selectedEmails.clear();
document.getElementById('selectAllCheckbox').checked = false;
updateBulkToolbar();
renderEmailList(emails);
}
function updateBulkToolbar() {
const toolbar = document.getElementById('bulkActionsToolbar');
const count = selectedEmails.size;
if (count > 0) {
toolbar.classList.add('active');
document.getElementById('selectedCount').textContent = count;
} else {
toolbar.classList.remove('active');
}
}
async function bulkArchive() {
if (selectedEmails.size === 0) return;
const count = selectedEmails.size;
try {
const response = await fetch('/api/v1/emails/bulk/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...selectedEmails])
});
if (!response.ok) throw new Error('Bulk archive failed');
showSuccess(`${count} emails arkiveret`);
clearSelection();
loadEmails();
} catch (error) {
showError('Kunne ikke arkivere emails');
}
}
async function bulkReprocess() {
if (selectedEmails.size === 0) return;
const count = selectedEmails.size;
try {
const response = await fetch('/api/v1/emails/bulk/reprocess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...selectedEmails])
});
if (!response.ok) throw new Error('Bulk reprocess failed');
showSuccess(`${count} emails genbehandlet`);
clearSelection();
loadEmails();
} catch (error) {
showError('Kunne ikke genbehandle emails');
}
}
async function bulkDelete() {
if (selectedEmails.size === 0) return;
const count = selectedEmails.size;
if (!confirm(`Slet ${count} emails permanent?`)) return;
try {
const response = await fetch('/api/v1/emails/bulk/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...selectedEmails])
});
if (!response.ok) throw new Error('Bulk delete failed');
showSuccess(`${count} emails slettet`);
clearSelection();
loadEmails();
} catch (error) {
showError('Kunne ikke slette emails');
}
}
async function showRulesModal() {
const modal = new bootstrap.Modal(document.getElementById('emailRulesModal'));
modal.show();
try {
const response = await fetch('/api/v1/email-rules');
const rules = await response.json();
const tbody = document.getElementById('rulesTableBody');
tbody.innerHTML = rules.map(rule => `
<tr>
<td><span class="badge bg-secondary">${rule.priority}</span></td>
<td>${rule.name}</td>
<td>${rule.action_type}</td>
<td>
<span class="badge bg-${rule.enabled ? 'success' : 'secondary'}">
${rule.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td>
<button class="btn btn-sm btn-light border" onclick="editRule(${rule.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-light border text-danger" onclick="deleteRule(${rule.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Failed to load rules:', error);
}
}
function startAutoRefresh() {
autoRefreshInterval = setInterval(() => {
loadEmails();
loadStats();
}, 30000);
}
function getInitials(name) {
if (!name) return '?';
const parts = name.split(/[\s@]+/);
return parts.slice(0, 2).map(p => p[0].toUpperCase()).join('');
}
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'Lige nu';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}t`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`;
return date.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
}
function formatDateTime(dateString) {
const date = new Date(dateString);
return date.toLocaleString('da-DK', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatClassification(classification) {
const map = {
'invoice': 'Faktura',
'order_confirmation': 'Ordre',
'freight_note': 'Fragt',
'time_confirmation': 'Tid',
'case_notification': 'Sag',
'bankruptcy': 'Konkurs',
'spam': 'Spam',
'general': 'Generel'
};
return map[classification] || classification;
}
function getStatusBadge(email) {
const status = email.status || 'new';
const approvalStatus = email.approval_status;
if (status === 'awaiting_user_action') {
return '<span class="badge bg-warning text-dark badge-sm ms-1"><i class="bi bi-person-check me-1"></i>Afventer</span>';
}
if (status === 'processed' || status === 'archived') {
return '<span class="badge bg-success badge-sm ms-1"><i class="bi bi-check-circle me-1"></i>Behandlet</span>';
}
if (status === 'flagged' || approvalStatus === 'pending_review') {
return '<span class="badge bg-warning badge-sm ms-1"><i class="bi bi-flag me-1"></i>Til review</span>';
}
if (status === 'error') {
return '<span class="badge bg-danger badge-sm ms-1"><i class="bi bi-exclamation-circle me-1"></i>Fejl</span>';
}
// Don't show badge for "new" status - it's the default
return '';
}
function getFileIcon(contentType) {
if (contentType?.includes('pdf')) return 'pdf';
if (contentType?.includes('image')) return 'image';
if (contentType?.includes('zip') || contentType?.includes('compressed')) return 'zip';
if (contentType?.includes('word') || contentType?.includes('document')) return 'word';
if (contentType?.includes('excel') || contentType?.includes('spreadsheet')) return 'excel';
return 'text';
}
function formatFileSize(bytes) {
if (!bytes) return 'N/A';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function canPreviewFile(contentType) {
if (!contentType) return false;
const previewable = [
'application/pdf',
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'text/plain',
'text/html'
];
return previewable.some(type => contentType.toLowerCase().includes(type.toLowerCase()));
}
function previewAttachment(emailId, attachmentId, filename, contentType) {
const modal = new bootstrap.Modal(document.getElementById('attachmentPreviewModal'));
const titleEl = document.getElementById('attachmentPreviewTitle');
const contentEl = document.getElementById('attachmentPreviewContent');
const downloadBtn = document.getElementById('attachmentDownloadBtn');
// Set title and download link
titleEl.textContent = filename;
downloadBtn.href = `/api/v1/emails/${emailId}/attachments/${attachmentId}`;
downloadBtn.download = filename;
// Show loading spinner
contentEl.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
`;
// Show modal
modal.show();
// Build preview based on content type
const url = `/api/v1/emails/${emailId}/attachments/${attachmentId}`;
if (contentType.includes('pdf')) {
// PDF preview using embed
contentEl.innerHTML = `
<embed src="${url}"
type="application/pdf"
width="100%"
height="100%"
style="min-height: 70vh;" />
`;
} else if (contentType.startsWith('image/')) {
// Image preview
contentEl.innerHTML = `
<img src="${url}"
alt="${escapeHtml(filename)}"
style="max-width: 100%; height: auto; display: block; margin: 0 auto;" />
`;
} else if (contentType.includes('text/')) {
// Text preview
fetch(url)
.then(response => response.text())
.then(text => {
contentEl.innerHTML = `
<pre style="padding: 2rem; margin: 0; white-space: pre-wrap; word-wrap: break-word; font-family: monospace; text-align: left;">${escapeHtml(text)}</pre>
`;
})
.catch(error => {
contentEl.innerHTML = `
<div class="alert alert-danger m-4">
<i class="bi bi-exclamation-triangle me-2"></i>
Kunne ikke indlæse fil: ${error.message}
</div>
`;
});
} else {
contentEl.innerHTML = `
<div class="alert alert-info m-4">
<i class="bi bi-info-circle me-2"></i>
Denne filtype kan ikke vises. Brug download-knappen.
</div>
`;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showSuccess(message) {
alert(message);
}
function showInfo(message) {
console.log('', message);
// Could show toast notification here instead of alert
}
function showSuccess(message) {
console.log('✅', message);
// Could show toast notification here instead of alert
}
function showError(message) {
alert('Fejl: ' + message);
}
window.addEventListener('beforeunload', () => {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
});
// ========== Workflow Management Functions ==========
function openWorkflowManager() {
const modal = new bootstrap.Modal(document.getElementById('workflowManagerModal'));
modal.show();
loadWorkflows();
loadWorkflowActions();
loadWorkflowExecutions();
loadWorkflowStats();
}
async function loadWorkflows() {
try {
const response = await fetch('/api/v1/workflows');
const workflows = await response.json();
const container = document.getElementById('workflowsList');
if (workflows.length === 0) {
container.innerHTML = '<div class="text-center text-muted py-4">Ingen workflows endnu</div>';
return;
}
container.innerHTML = workflows.map(wf => `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
<h6 class="mb-0">${wf.name}</h6>
<span class="badge bg-${wf.enabled ? 'success' : 'secondary'} badge-sm">
${wf.enabled ? 'Aktiv' : 'Deaktiveret'}
</span>
<span class="badge bg-info badge-sm">${wf.classification_trigger}</span>
</div>
<p class="text-muted small mb-2">${wf.description || 'Ingen beskrivelse'}</p>
<div class="d-flex gap-3 small text-muted">
<span><i class="bi bi-arrow-repeat me-1"></i>${wf.execution_count || 0} eksekveret</span>
<span><i class="bi bi-check-circle me-1"></i>${wf.success_count || 0} success</span>
<span><i class="bi bi-exclamation-circle me-1"></i>${wf.failure_count || 0} fejl</span>
<span><i class="bi bi-bar-chart me-1"></i>Prioritet: ${wf.priority}</span>
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="editWorkflow(${wf.id})" title="Rediger">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-info" onclick="testWorkflow(${wf.id})" title="Test">
<i class="bi bi-play-circle"></i>
</button>
<button class="btn btn-outline-secondary" onclick="duplicateWorkflow(${wf.id})" title="Dupliker">
<i class="bi bi-files"></i>
</button>
<button class="btn btn-outline-secondary" onclick="exportWorkflow(${wf.id})" title="Export">
<i class="bi bi-download"></i>
</button>
<button class="btn btn-outline-${wf.enabled ? 'warning' : 'success'}"
onclick="toggleWorkflow(${wf.id})"
title="${wf.enabled ? 'Deaktiver' : 'Aktiver'}">
<i class="bi bi-${wf.enabled ? 'pause' : 'play'}-circle"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteWorkflow(${wf.id})" title="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading workflows:', error);
document.getElementById('workflowsList').innerHTML =
'<div class="alert alert-danger">Kunne ikke indlæse workflows</div>';
}
}
async function loadWorkflowActions() {
try {
const response = await fetch('/api/v1/workflow-actions');
const actions = await response.json();
const container = document.getElementById('actionsList');
const grouped = actions.reduce((acc, action) => {
const category = action.category || 'other';
if (!acc[category]) acc[category] = [];
acc[category].push(action);
return acc;
}, {});
container.innerHTML = Object.entries(grouped).map(([category, acts]) => `
<div class="col-12">
<h6 class="text-muted text-uppercase mb-3">${category}</h6>
</div>
${acts.map(action => `
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-2 mb-2">
<code class="bg-light px-2 py-1 rounded small">${action.action_code}</code>
</div>
<h6 class="card-title">${action.name}</h6>
<p class="card-text small text-muted">${action.description || 'Ingen beskrivelse'}</p>
${action.example_config ? `
<details class="small">
<summary class="text-primary" style="cursor: pointer;">Eksempel config</summary>
<pre class="bg-light p-2 mt-2 rounded"><code>${JSON.stringify(action.example_config, null, 2)}</code></pre>
</details>
` : ''}
</div>
</div>
</div>
`).join('')}
`).join('');
} catch (error) {
console.error('Error loading actions:', error);
}
}
async function loadWorkflowExecutions() {
try {
const response = await fetch('/api/v1/workflow-executions?limit=20');
const executions = await response.json();
const container = document.getElementById('executionsList');
if (executions.length === 0) {
container.innerHTML = '<div class="text-center text-muted py-4">Ingen executions endnu</div>';
return;
}
container.innerHTML = executions.map(exec => {
const statusColor = exec.status === 'completed' ? 'success' : exec.status === 'failed' ? 'danger' : 'warning';
const statusIcon = exec.status === 'completed' ? 'check-circle' : exec.status === 'failed' ? 'x-circle' : 'hourglass-split';
return `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="badge bg-${statusColor}">
<i class="bi bi-${statusIcon} me-1"></i>${exec.status}
</span>
<span class="ms-2 small text-muted">
Workflow ID: ${exec.workflow_id} | Email ID: ${exec.email_id}
</span>
</div>
<div class="text-end small text-muted">
<div>${exec.steps_completed}/${exec.steps_total || '?'} steps</div>
<div>${exec.execution_time_ms ? exec.execution_time_ms + 'ms' : '-'}</div>
</div>
</div>
${exec.error_message ? `
<div class="alert alert-danger alert-sm mt-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>${exec.error_message}
</div>
` : ''}
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading executions:', error);
}
}
async function loadWorkflowStats() {
try {
const response = await fetch('/api/v1/workflows/stats/summary');
const stats = await response.json();
const container = document.getElementById('workflowStats');
container.innerHTML = `
<div class="row g-3">
${stats.map(stat => {
const successRate = stat.success_rate || 0;
const barColor = successRate >= 80 ? 'success' : successRate >= 50 ? 'warning' : 'danger';
return `
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6 class="card-title">${stat.name}</h6>
<div class="d-flex justify-content-between mb-2">
<span class="badge bg-info">${stat.classification_trigger}</span>
<span class="badge bg-${stat.enabled ? 'success' : 'secondary'}">
${stat.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Success Rate</span>
<span>${successRate.toFixed(1)}%</span>
</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-${barColor}"
style="width: ${successRate}%"></div>
</div>
</div>
<div class="row g-2 small">
<div class="col-4 text-center">
<div class="text-muted">Total</div>
<div class="fw-bold">${stat.execution_count || 0}</div>
</div>
<div class="col-4 text-center">
<div class="text-muted">Success</div>
<div class="fw-bold text-success">${stat.success_count || 0}</div>
</div>
<div class="col-4 text-center">
<div class="text-muted">Fejl</div>
<div class="fw-bold text-danger">${stat.failure_count || 0}</div>
</div>
</div>
${stat.last_executed_at ? `
<div class="mt-2 pt-2 border-top small text-muted">
Sidst kørt: ${formatDateTime(stat.last_executed_at)}
</div>
` : ''}
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Workflow Step Builder State
let workflowSteps = [];
let availableActions = [];
let currentJsonView = false;
function createNewWorkflow() {
document.getElementById('workflowId').value = '';
document.getElementById('workflowName').value = '';
document.getElementById('workflowDescription').value = '';
document.getElementById('workflowTrigger').value = 'invoice';
document.getElementById('workflowConfidence').value = '0.70';
document.getElementById('workflowPriority').value = '100';
document.getElementById('workflowEnabled').checked = true;
// Initialize with example steps
workflowSteps = [
{
action: "link_to_vendor",
params: { match_by: "email" }
},
{
action: "mark_as_processed",
params: { status: "processed" }
}
];
renderWorkflowSteps();
currentJsonView = false;
document.getElementById('workflowStepsBuilder').classList.remove('d-none');
document.getElementById('workflowStepsJson').classList.add('d-none');
document.getElementById('workflowEditorTitle').textContent = 'Ny Workflow';
const modal = new bootstrap.Modal(document.getElementById('workflowEditorModal'));
modal.show();
// Load available actions
loadAvailableActionsForBuilder();
}
async function editWorkflow(id) {
try {
const response = await fetch(`/api/v1/workflows/${id}`);
const workflow = await response.json();
document.getElementById('workflowId').value = workflow.id;
document.getElementById('workflowName').value = workflow.name;
document.getElementById('workflowDescription').value = workflow.description || '';
document.getElementById('workflowTrigger').value = workflow.classification_trigger;
document.getElementById('workflowConfidence').value = workflow.confidence_threshold;
document.getElementById('workflowPriority').value = workflow.priority;
document.getElementById('workflowEnabled').checked = workflow.enabled;
// Load steps into builder
workflowSteps = workflow.workflow_steps || [];
renderWorkflowSteps();
currentJsonView = false;
document.getElementById('workflowStepsBuilder').classList.remove('d-none');
document.getElementById('workflowStepsJson').classList.add('d-none');
document.getElementById('workflowEditorTitle').textContent = 'Rediger Workflow';
const modal = new bootstrap.Modal(document.getElementById('workflowEditorModal'));
modal.show();
loadAvailableActionsForBuilder();
} catch (error) {
console.error('Error loading workflow:', error);
alert('Kunne ikke indlæse workflow');
}
}
async function saveWorkflow() {
try {
// If in JSON view, parse JSON back to steps
if (currentJsonView) {
try {
workflowSteps = JSON.parse(document.getElementById('workflowStepsJson').value);
} catch (e) {
alert('Ugyldig JSON: ' + e.message);
return;
}
}
const id = document.getElementById('workflowId').value;
const data = {
name: document.getElementById('workflowName').value,
description: document.getElementById('workflowDescription').value,
classification_trigger: document.getElementById('workflowTrigger').value,
confidence_threshold: parseFloat(document.getElementById('workflowConfidence').value),
priority: parseInt(document.getElementById('workflowPriority').value),
enabled: document.getElementById('workflowEnabled').checked,
workflow_steps: workflowSteps,
stop_on_match: true
};
const url = id ? `/api/v1/workflows/${id}` : '/api/v1/workflows';
const method = id ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Save failed');
bootstrap.Modal.getInstance(document.getElementById('workflowEditorModal')).hide();
loadWorkflows();
showNotification('Workflow gemt!', 'success');
} catch (error) {
console.error('Error saving workflow:', error);
alert('Kunne ikke gemme workflow: ' + error.message);
}
}
// ========== Workflow Step Builder Functions ==========
async function loadAvailableActionsForBuilder() {
if (availableActions.length > 0) return; // Already loaded
try {
const response = await fetch('/api/v1/workflow-actions');
availableActions = await response.json();
} catch (error) {
console.error('Error loading actions:', error);
}
}
function addWorkflowStep() {
workflowSteps.push({
action: "mark_as_processed",
params: { status: "processed" }
});
renderWorkflowSteps();
}
function removeWorkflowStep(index) {
if (confirm('Slet dette step?')) {
workflowSteps.splice(index, 1);
renderWorkflowSteps();
}
}
function moveStepUp(index) {
if (index > 0) {
[workflowSteps[index], workflowSteps[index - 1]] = [workflowSteps[index - 1], workflowSteps[index]];
renderWorkflowSteps();
}
}
function moveStepDown(index) {
if (index < workflowSteps.length - 1) {
[workflowSteps[index], workflowSteps[index + 1]] = [workflowSteps[index + 1], workflowSteps[index]];
renderWorkflowSteps();
}
}
function updateStepAction(index, action) {
workflowSteps[index].action = action;
// Reset params with defaults for the new action
const actionDef = availableActions.find(a => a.action_code === action);
if (actionDef && actionDef.example_config) {
workflowSteps[index].params = { ...actionDef.example_config };
} else {
workflowSteps[index].params = {};
}
renderWorkflowSteps();
}
function updateStepParam(index, paramKey, paramValue) {
if (!workflowSteps[index].params) {
workflowSteps[index].params = {};
}
// Try to parse as JSON for complex values
try {
if (paramValue.startsWith('{') || paramValue.startsWith('[')) {
workflowSteps[index].params[paramKey] = JSON.parse(paramValue);
} else if (paramValue === 'true' || paramValue === 'false') {
workflowSteps[index].params[paramKey] = paramValue === 'true';
} else if (!isNaN(paramValue) && paramValue !== '') {
workflowSteps[index].params[paramKey] = Number(paramValue);
} else {
workflowSteps[index].params[paramKey] = paramValue;
}
} catch (e) {
workflowSteps[index].params[paramKey] = paramValue;
}
}
function renderWorkflowSteps() {
const container = document.getElementById('stepsList');
const emptyMsg = document.getElementById('emptyStepsMessage');
if (workflowSteps.length === 0) {
container.innerHTML = '';
emptyMsg.classList.remove('d-none');
return;
}
emptyMsg.classList.add('d-none');
container.innerHTML = workflowSteps.map((step, index) => {
const actionDef = availableActions.find(a => a.action_code === step.action);
const actionName = actionDef ? actionDef.name : step.action;
const actionIcon = getActionIcon(step.action);
return `
<div class="workflow-step-item"
data-index="${index}"
draggable="true"
ondragstart="handleDragStart(event, ${index})"
ondragover="handleDragOver(event)"
ondrop="handleDrop(event, ${index})"
ondragend="handleDragEnd(event)">
<div class="step-header">
<i class="bi bi-grip-vertical drag-handle me-2"></i>
<div class="step-number">${index + 1}</div>
<i class="bi bi-${actionIcon} step-icon"></i>
<div class="flex-grow-1">
<select class="form-select form-select-sm"
onchange="updateStepAction(${index}, this.value)">
${getActionOptions(step.action)}
</select>
</div>
<div class="step-actions">
<button type="button" class="btn btn-sm btn-light"
onclick="moveStepUp(${index})"
${index === 0 ? 'disabled' : ''}>
<i class="bi bi-arrow-up"></i>
</button>
<button type="button" class="btn btn-sm btn-light"
onclick="moveStepDown(${index})"
${index === workflowSteps.length - 1 ? 'disabled' : ''}>
<i class="bi bi-arrow-down"></i>
</button>
<button type="button" class="btn btn-sm btn-light text-danger"
onclick="removeWorkflowStep(${index})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="step-body">
<div class="small text-muted mb-2">${actionName}</div>
${renderStepParams(step, index)}
</div>
</div>
${index < workflowSteps.length - 1 ? '<div class="step-connector"></div>' : ''}
`;
}).join('');
}
// Drag & Drop handlers
let draggedIndex = null;
function handleDragStart(event, index) {
draggedIndex = index;
event.target.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
function handleDrop(event, targetIndex) {
event.preventDefault();
if (draggedIndex !== null && draggedIndex !== targetIndex) {
// Move the step
const item = workflowSteps.splice(draggedIndex, 1)[0];
workflowSteps.splice(targetIndex, 0, item);
renderWorkflowSteps();
}
}
function handleDragEnd(event) {
event.target.classList.remove('dragging');
draggedIndex = null;
}
function getActionOptions(currentAction) {
const grouped = availableActions.reduce((acc, action) => {
const category = action.category || 'other';
if (!acc[category]) acc[category] = [];
acc[category].push(action);
return acc;
}, {});
let options = '';
for (const [category, actions] of Object.entries(grouped)) {
options += `<optgroup label="${category}">`;
actions.forEach(action => {
options += `<option value="${action.action_code}" ${action.action_code === currentAction ? 'selected' : ''}>
${action.name}
</option>`;
});
options += '</optgroup>';
}
return options;
}
function addStepParam(index) {
const key = prompt('Parameter navn:');
if (!key) return;
if (!workflowSteps[index].params) {
workflowSteps[index].params = {};
}
workflowSteps[index].params[key] = '';
renderWorkflowSteps();
}
function removeStepParam(index, paramKey) {
if (confirm(`Slet parameter "${paramKey}"?`)) {
delete workflowSteps[index].params[paramKey];
renderWorkflowSteps();
}
}
function renderStepParams(step, index) {
const actionDef = availableActions.find(a => a.action_code === step.action);
let html = '';
if (!step.params || Object.keys(step.params).length === 0) {
html = '<div class="text-muted small">Ingen parametre</div>';
} else {
html = Object.entries(step.params).map(([key, value]) => {
const valueStr = typeof value === 'object' ? JSON.stringify(value) : String(value);
return `
<div class="step-param-row d-flex gap-2 align-items-start">
<div class="flex-grow-1">
<label class="form-label small mb-1">${key}</label>
<input type="text"
class="form-control form-control-sm"
value="${valueStr.replace(/"/g, '&quot;')}"
onchange="updateStepParam(${index}, '${key}', this.value)"
placeholder="Værdi">
</div>
<button type="button"
class="btn btn-sm btn-light text-danger mt-4"
onclick="removeStepParam(${index}, '${key}')"
title="Slet parameter">
<i class="bi bi-x-lg"></i>
</button>
</div>
`;
}).join('');
}
html += `
<button type="button"
class="btn btn-sm btn-outline-secondary w-100 mt-2"
onclick="addStepParam(${index})">
<i class="bi bi-plus-circle me-1"></i>Tilføj Parameter
</button>
`;
return html;
}
function getActionIcon(action) {
const iconMap = {
'create_ticket': 'ticket',
'create_time_entry': 'clock',
'link_to_vendor': 'link-45deg',
'link_to_customer': 'people',
'extract_invoice_data': 'file-text',
'extract_tracking_number': 'box-seam',
'send_slack_notification': 'chat-left-text',
'send_email_notification': 'envelope',
'mark_as_processed': 'check-circle',
'flag_for_review': 'flag',
'conditional_branch': 'signpost-split'
};
return iconMap[action] || 'gear';
}
function toggleJsonView() {
currentJsonView = !currentJsonView;
if (currentJsonView) {
// Switch to JSON view
document.getElementById('workflowStepsJson').value = JSON.stringify(workflowSteps, null, 2);
document.getElementById('workflowStepsBuilder').classList.add('d-none');
document.getElementById('workflowStepsJson').classList.remove('d-none');
} else {
// Switch to visual view - try to parse JSON
try {
const jsonValue = document.getElementById('workflowStepsJson').value;
if (jsonValue.trim()) {
workflowSteps = JSON.parse(jsonValue);
}
renderWorkflowSteps();
document.getElementById('workflowStepsBuilder').classList.remove('d-none');
document.getElementById('workflowStepsJson').classList.add('d-none');
} catch (e) {
alert('Ugyldig JSON. Kan ikke skifte til visual view: ' + e.message);
currentJsonView = true;
}
}
}
async function toggleWorkflow(id) {
try {
const response = await fetch(`/api/v1/workflows/${id}/toggle`, {
method: 'POST'
});
if (!response.ok) throw new Error('Toggle failed');
loadWorkflows();
showNotification('Workflow status ændret', 'success');
} catch (error) {
console.error('Error toggling workflow:', error);
alert('Kunne ikke ændre workflow status');
}
}
async function deleteWorkflow(id) {
if (!confirm('Er du sikker på at du vil slette denne workflow?')) return;
try {
const response = await fetch(`/api/v1/workflows/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
loadWorkflows();
showNotification('Workflow slettet', 'success');
} catch (error) {
console.error('Error deleting workflow:', error);
alert('Kunne ikke slette workflow');
}
}
async function executeWorkflowsForEmail() {
if (!currentEmailId) {
alert('Ingen email valgt');
return;
}
if (!confirm('Vil du køre workflows for denne email?')) return;
try {
showNotification('Kører workflows...', 'info');
const response = await fetch(`/api/v1/emails/${currentEmailId}/execute-workflows`, {
method: 'POST'
});
if (!response.ok) throw new Error('Execution failed');
const result = await response.json();
if (result.workflows_executed > 0) {
showNotification(
`${result.workflows_succeeded}/${result.workflows_executed} workflows succesfulde`,
'success'
);
// Show detailed results
if (result.details && result.details.length > 0) {
const summary = result.details.map(d =>
`${d.workflow_name}: ${d.status} (${d.steps_completed}/${d.steps_total} steps)`
).join('\n');
setTimeout(() => {
alert('Workflow Resultat:\n\n' + summary);
}, 500);
}
// Reload email to see updated status
loadEmailDetail(currentEmailId);
} else {
showNotification('Ingen workflows matchede denne email', 'warning');
}
} catch (error) {
console.error('Error executing workflows:', error);
showNotification('❌ Kunne ikke køre workflows', 'danger');
}
}
// ========== Action Guide ==========
function showActionGuide() {
const guideModal = document.createElement('div');
guideModal.className = 'modal fade';
guideModal.id = 'actionGuideModal';
guideModal.innerHTML = `
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-book me-2"></i>Workflow Actions Quick Guide
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Tip:</strong> Actions udføres sekventielt i den rækkefølge de er defineret. Brug drag-and-drop til at ændre rækkefølgen.
</div>
<div class="accordion" id="actionsAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#actionLinking">
<i class="bi bi-link-45deg me-2"></i>Linking Actions
</button>
</h2>
<div id="actionLinking" class="accordion-collapse collapse show" data-bs-parent="#actionsAccordion">
<div class="accordion-body">
<div class="mb-3">
<strong><code>link_to_vendor</code></strong>
<p class="small mb-1">Link email til leverandør baseret på sender email</p>
<pre class="bg-light p-2 small"><code>{ "action": "link_to_vendor", "params": { "match_by": "email" } }</code></pre>
</div>
<div class="mb-3">
<strong><code>link_to_customer</code></strong>
<p class="small mb-1">Link email til kunde</p>
<pre class="bg-light p-2 small"><code>{ "action": "link_to_customer", "params": {} }</code></pre>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#actionExtraction">
<i class="bi bi-file-text me-2"></i>Data Extraction
</button>
</h2>
<div id="actionExtraction" class="accordion-collapse collapse" data-bs-parent="#actionsAccordion">
<div class="accordion-body">
<div class="mb-3">
<strong><code>extract_invoice_data</code></strong>
<p class="small mb-1">Ekstraher faktura data fra PDF attachments</p>
<pre class="bg-light p-2 small"><code>{ "action": "extract_invoice_data", "params": {} }</code></pre>
</div>
<div class="mb-3">
<strong><code>extract_tracking_number</code></strong>
<p class="small mb-1">Ekstraher tracking/case nummer fra subject/body</p>
<pre class="bg-light p-2 small"><code>{ "action": "extract_tracking_number", "params": { "pattern": "CC[0-9]{4}" } }</code></pre>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#actionTicketing">
<i class="bi bi-ticket-perforated me-2"></i>Ticket Creation
</button>
</h2>
<div id="actionTicketing" class="accordion-collapse collapse" data-bs-parent="#actionsAccordion">
<div class="accordion-body">
<div class="mb-3">
<strong><code>create_ticket</code></strong>
<p class="small mb-1">Opret support ticket/case fra email</p>
<pre class="bg-light p-2 small"><code>{ "action": "create_ticket", "params": { "module": "support_cases", "priority": "normal" } }</code></pre>
</div>
<div class="mb-3">
<strong><code>create_time_entry</code></strong>
<p class="small mb-1">Opret time registrering</p>
<pre class="bg-light p-2 small"><code>{ "action": "create_time_entry", "params": {} }</code></pre>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#actionNotification">
<i class="bi bi-bell me-2"></i>Notifications
</button>
</h2>
<div id="actionNotification" class="accordion-collapse collapse" data-bs-parent="#actionsAccordion">
<div class="accordion-body">
<div class="mb-3">
<strong><code>send_slack_notification</code></strong>
<p class="small mb-1">Send besked til Slack kanal</p>
<pre class="bg-light p-2 small"><code>{ "action": "send_slack_notification", "params": { "channel": "#alerts", "message": "Ny email modtaget" } }</code></pre>
</div>
<div class="mb-3">
<strong><code>send_email_notification</code></strong>
<p class="small mb-1">Send email notifikation</p>
<pre class="bg-light p-2 small"><code>{ "action": "send_email_notification", "params": { "recipient": "admin@example.com" } }</code></pre>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#actionProcessing">
<i class="bi bi-gear me-2"></i>Processing Control
</button>
</h2>
<div id="actionProcessing" class="accordion-collapse collapse" data-bs-parent="#actionsAccordion">
<div class="accordion-body">
<div class="mb-3">
<strong><code>mark_as_processed</code></strong>
<p class="small mb-1">Marker email som processed og flyt til Processed folder</p>
<pre class="bg-light p-2 small"><code>{ "action": "mark_as_processed", "params": {} }</code></pre>
</div>
<div class="mb-3">
<strong><code>flag_for_review</code></strong>
<p class="small mb-1">Flag email for manuel review</p>
<pre class="bg-light p-2 small"><code>{ "action": "flag_for_review", "params": { "reason": "needs_review" } }</code></pre>
</div>
</div>
</div>
</div>
</div>
<div class="alert alert-warning mt-4">
<strong><i class="bi bi-exclamation-triangle me-2"></i>Vigtig note:</strong>
<ul class="mb-0 mt-2">
<li>Actions udføres i rækkefølge - planlæg logisk flow</li>
<li>Hvis en action fejler, stoppes workflow (medmindre fejlhåndtering er implementeret)</li>
<li>Test altid workflows før deployment i produktion</li>
</ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
`;
document.body.appendChild(guideModal);
const bsModal = new bootstrap.Modal(guideModal);
bsModal.show();
guideModal.addEventListener('hidden.bs.modal', () => guideModal.remove());
}
// ========== Workflow Templates ==========
const WORKFLOW_TEMPLATES = {
'invoice_processing': {
name: 'Leverandør Faktura Processing',
description: 'Automatisk behandling af leverandør fakturaer - link til vendor, ekstraher data, marker som processed',
classification_trigger: 'invoice',
confidence_threshold: 0.70,
priority: 50,
workflow_steps: [
{ action: 'link_to_vendor', params: { match_by: 'email' } },
{ action: 'extract_invoice_data', params: {} },
{ action: 'mark_as_processed', params: {} }
]
},
'time_confirmation': {
name: 'Time Confirmation Auto-Link',
description: 'Automatisk link af time confirmations til sager baseret på CC#### i subject',
classification_trigger: 'time_confirmation',
confidence_threshold: 0.60,
priority: 30,
workflow_steps: [
{ action: 'extract_tracking_number', params: { pattern: 'CC[0-9]{4}' } },
{ action: 'create_ticket', params: { module: 'time_tracking' } },
{ action: 'mark_as_processed', params: {} }
]
},
'spam_handler': {
name: 'Spam Detector og Cleanup',
description: 'Marker spam emails og flyt dem til processed folder',
classification_trigger: 'spam',
confidence_threshold: 0.80,
priority: 10,
workflow_steps: [
{ action: 'flag_for_review', params: { reason: 'spam_detected' } },
{ action: 'mark_as_processed', params: {} }
]
},
'freight_note': {
name: 'Fragtbrev Processing',
description: 'Behandling af fragtbreve - ekstraher tracking nummer og link til kunde',
classification_trigger: 'freight_note',
confidence_threshold: 0.65,
priority: 40,
workflow_steps: [
{ action: 'extract_tracking_number', params: { pattern: '\\b[0-9]{10,}\\b' } },
{ action: 'link_to_customer', params: {} },
{ action: 'mark_as_processed', params: {} }
]
},
'bankruptcy_alert': {
name: 'Konkurs Alert',
description: 'Send notifikation når konkurs email modtages',
classification_trigger: 'bankruptcy',
confidence_threshold: 0.75,
priority: 5,
workflow_steps: [
{ action: 'send_slack_notification', params: { channel: '#alerts', message: '🚨 Konkurs alert modtaget' } },
{ action: 'flag_for_review', params: { reason: 'bankruptcy_detected' } },
{ action: 'mark_as_processed', params: {} }
]
},
'low_confidence': {
name: 'Low Confidence Review',
description: 'Flag emails med lav confidence for manuel review',
classification_trigger: 'general',
confidence_threshold: 0.30,
priority: 200,
workflow_steps: [
{ action: 'flag_for_review', params: { reason: 'low_confidence' } }
]
}
};
function showWorkflowTemplates() {
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'templatesModal';
modal.innerHTML = `
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-file-earmark-text me-2"></i>Workflow Templates
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted">Vælg en template for at oprette en ny workflow baseret på common use cases</p>
<div class="row g-3">
${Object.entries(WORKFLOW_TEMPLATES).map(([key, template]) => `
<div class="col-md-6">
<div class="card h-100 workflow-template-card" style="cursor: pointer;"
onclick="createFromTemplate('${key}')">
<div class="card-body">
<h6 class="card-title">${template.name}</h6>
<p class="card-text small text-muted">${template.description}</p>
<div class="d-flex gap-2 mt-2">
<span class="badge bg-info">${template.classification_trigger}</span>
<span class="badge bg-secondary">${template.workflow_steps.length} steps</span>
<span class="badge bg-primary">Prioritet: ${template.priority}</span>
</div>
</div>
</div>
</div>
`).join('')}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
modal.addEventListener('hidden.bs.modal', () => modal.remove());
}
function createFromTemplate(templateKey) {
const template = WORKFLOW_TEMPLATES[templateKey];
if (!template) return;
// Close templates modal
bootstrap.Modal.getInstance(document.getElementById('templatesModal')).hide();
// Populate workflow editor with template data
document.getElementById('workflowId').value = '';
document.getElementById('workflowName').value = template.name;
document.getElementById('workflowDescription').value = template.description;
document.getElementById('workflowTrigger').value = template.classification_trigger;
document.getElementById('workflowConfidence').value = template.confidence_threshold;
document.getElementById('workflowPriority').value = template.priority;
document.getElementById('workflowEnabled').checked = true;
workflowSteps = JSON.parse(JSON.stringify(template.workflow_steps));
renderWorkflowSteps();
document.getElementById('workflowEditorTitle').textContent = 'Ny Workflow fra Template';
const modal = new bootstrap.Modal(document.getElementById('workflowEditorModal'));
modal.show();
loadAvailableActionsForBuilder();
showNotification('Template indlæst - tilpas og gem', 'info');
}
// ========== Duplicate Workflow ==========
async function duplicateWorkflow(id) {
try {
const response = await fetch(`/api/v1/workflows/${id}`);
const workflow = await response.json();
// Populate editor with duplicated data
document.getElementById('workflowId').value = '';
document.getElementById('workflowName').value = workflow.name + ' (kopi)';
document.getElementById('workflowDescription').value = workflow.description || '';
document.getElementById('workflowTrigger').value = workflow.classification_trigger;
document.getElementById('workflowConfidence').value = workflow.confidence_threshold;
document.getElementById('workflowPriority').value = workflow.priority + 1; // Slightly lower priority
document.getElementById('workflowEnabled').checked = false; // Disabled by default
workflowSteps = JSON.parse(JSON.stringify(workflow.workflow_steps || []));
renderWorkflowSteps();
document.getElementById('workflowEditorTitle').textContent = 'Dupliker Workflow';
const modal = new bootstrap.Modal(document.getElementById('workflowEditorModal'));
modal.show();
loadAvailableActionsForBuilder();
showNotification('Workflow duplikeret - tilpas og gem', 'info');
} catch (error) {
console.error('Error duplicating workflow:', error);
alert('Kunne ikke duplikere workflow');
}
}
// ========== Export/Import Workflow ==========
async function exportWorkflow(id) {
try {
const response = await fetch(`/api/v1/workflows/${id}`);
const workflow = await response.json();
// Remove database-specific fields
delete workflow.id;
delete workflow.created_at;
delete workflow.updated_at;
delete workflow.execution_count;
delete workflow.success_count;
delete workflow.failure_count;
delete workflow.last_executed_at;
// Create download
const blob = new Blob([JSON.stringify(workflow, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `workflow_${workflow.name.toLowerCase().replace(/\\s+/g, '_')}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('Workflow exporteret', 'success');
} catch (error) {
console.error('Error exporting workflow:', error);
alert('Kunne ikke exportere workflow');
}
}
function importWorkflow() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
try {
const file = e.target.files[0];
const text = await file.text();
const workflow = JSON.parse(text);
// Validate required fields
if (!workflow.name || !workflow.classification_trigger || !workflow.workflow_steps) {
throw new Error('Ugyldig workflow fil - mangler required fields');
}
// Populate editor
document.getElementById('workflowId').value = '';
document.getElementById('workflowName').value = workflow.name + ' (imported)';
document.getElementById('workflowDescription').value = workflow.description || '';
document.getElementById('workflowTrigger').value = workflow.classification_trigger;
document.getElementById('workflowConfidence').value = workflow.confidence_threshold || 0.70;
document.getElementById('workflowPriority').value = workflow.priority || 100;
document.getElementById('workflowEnabled').checked = workflow.enabled !== false;
workflowSteps = workflow.workflow_steps;
renderWorkflowSteps();
document.getElementById('workflowEditorTitle').textContent = 'Import Workflow';
const modal = new bootstrap.Modal(document.getElementById('workflowEditorModal'));
modal.show();
loadAvailableActionsForBuilder();
showNotification('Workflow importeret - gennemse og gem', 'success');
} catch (error) {
console.error('Error importing workflow:', error);
alert('Kunne ikke importere workflow: ' + error.message);
}
};
input.click();
}
// ========== Test Workflow ==========
async function testWorkflow(id) {
try {
const response = await fetch(`/api/v1/workflows/${id}`);
const workflow = await response.json();
// Show test dialog
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'testWorkflowModal';
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-play-circle me-2"></i>Test Workflow: ${workflow.name}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted">Vælg en email at teste workflow på:</p>
<div class="mb-3">
<label class="form-label">Email ID</label>
<input type="number" class="form-control" id="testEmailId" placeholder="Email ID">
<small class="text-muted">Eller vælg en email fra listen først</small>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Test mode:</strong> Workflow vil køre mod den valgte email, men du kan se resultaterne før de gemmes.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="executeTestWorkflow(${id})">
<i class="bi bi-play me-1"></i>Kør Test
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
modal.addEventListener('hidden.bs.modal', () => modal.remove());
// Pre-fill with current email if selected
if (currentEmailId) {
document.getElementById('testEmailId').value = currentEmailId;
}
} catch (error) {
console.error('Error preparing test:', error);
alert('Kunne ikke forberede test');
}
}
async function executeTestWorkflow(workflowId) {
const emailId = document.getElementById('testEmailId').value;
if (!emailId) {
alert('Vælg en email ID');
return;
}
try {
showNotification('Kører test...', 'info');
const response = await fetch(`/api/v1/emails/${emailId}/execute-workflows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: workflowId, test_mode: true })
});
if (!response.ok) throw new Error('Test failed');
const result = await response.json();
// Close test modal
bootstrap.Modal.getInstance(document.getElementById('testWorkflowModal')).hide();
// Show results
const resultsModal = document.createElement('div');
resultsModal.className = 'modal fade';
resultsModal.id = 'testResultsModal';
resultsModal.innerHTML = `
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-clipboard-check me-2"></i>Test Resultater
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-${result.status === 'completed' ? 'success' : 'warning'}">
<strong>Status:</strong> ${result.status}
${result.steps_completed ? `<br><strong>Steps:</strong> ${result.steps_completed}/${result.steps_total}` : ''}
</div>
${result.step_results ? `
<h6>Step Results:</h6>
<div class="list-group">
${result.step_results.map((step, idx) => `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">Step ${idx + 1}: ${step.action}</h6>
<span class="badge bg-${step.status === 'success' ? 'success' : 'danger'}">${step.status}</span>
</div>
</div>
<pre class="mt-2 mb-0 small"><code>${JSON.stringify(step.result || step.error, null, 2)}</code></pre>
</div>
`).join('')}
</div>
` : ''}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
`;
document.body.appendChild(resultsModal);
const bsModal = new bootstrap.Modal(resultsModal);
bsModal.show();
resultsModal.addEventListener('hidden.bs.modal', () => resultsModal.remove());
showNotification('Test gennemført', 'success');
} catch (error) {
console.error('Error executing test:', error);
alert('Test fejlede: ' + error.message);
}
}
async function testCurrentWorkflow() {
// Test the workflow currently being edited (without saving)
try {
// Validate form
const name = document.getElementById('workflowName').value;
if (!name) {
alert('Indtast workflow navn først');
return;
}
// Build workflow config
const workflowConfig = {
name: name,
classification_trigger: document.getElementById('workflowTrigger').value,
confidence_threshold: parseFloat(document.getElementById('workflowConfidence').value),
workflow_steps: currentJsonView ?
JSON.parse(document.getElementById('workflowStepsJson').value) :
workflowSteps
};
showNotification('Test konfiguration valideret ✓', 'success');
// Show email selector
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'testCurrentWorkflowModal';
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Test Workflow</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Vælg email at teste på:</p>
<input type="number" class="form-control" id="testCurrentEmailId"
placeholder="Email ID" value="${currentEmailId || ''}">
<div class="alert alert-info mt-3">
Workflow er ikke gemt endnu - dette er kun en test preview
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="alert('Test funktionalitet kommer snart')">
Kør Test
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
modal.addEventListener('hidden.bs.modal', () => modal.remove());
} catch (error) {
console.error('Error testing workflow:', error);
alert('Kunne ikke teste workflow: ' + error.message);
}
}
function showNotification(message, type = 'info') {
// Simple toast notification
const toast = document.createElement('div');
toast.className = `alert alert-${type} position-fixed bottom-0 end-0 m-3`;
toast.style.zIndex = '9999';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Email Upload Functionality
function toggleUploadZone() {
const uploadZone = document.getElementById('emailUploadZone');
if (uploadZone.style.display === 'none') {
uploadZone.style.display = 'block';
} else {
uploadZone.style.display = 'none';
}
}
// Setup drag and drop
document.addEventListener('DOMContentLoaded', () => {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
if (!dropZone || !fileInput) return;
// Click to select files
dropZone.addEventListener('click', () => fileInput.click());
// Drag and drop events
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
uploadEmailFiles(files);
});
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
uploadEmailFiles(files);
});
});
async function uploadEmailFiles(files) {
if (files.length === 0) return;
console.log(`📤 Starting upload of ${files.length} file(s)`);
const uploadProgress = document.getElementById('uploadProgress');
const progressBar = document.getElementById('progressBar');
const uploadStatus = document.getElementById('uploadStatus');
// Show progress
uploadProgress.style.display = 'block';
progressBar.style.width = '0%';
uploadStatus.textContent = `Uploader ${files.length} fil(er)...`;
const formData = new FormData();
files.forEach(file => {
console.log(`📎 Adding file to upload: ${file.name} (${file.size} bytes)`);
formData.append('files', file);
});
try {
console.log('🌐 Sending upload request to /api/v1/emails/upload');
const response = await fetch('/api/v1/emails/upload', {
method: 'POST',
body: formData
});
console.log(`📡 Response status: ${response.status}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
console.log('✅ Upload result:', result);
// Update progress
progressBar.style.width = '100%';
// Show result
const totalFiles = files.length;
const { uploaded, duplicates, failed, skipped } = result;
let statusMessage = [];
if (uploaded > 0) statusMessage.push(`${uploaded} uploadet`);
if (duplicates > 0) statusMessage.push(`${duplicates} eksisterer allerede`);
if (failed > 0) statusMessage.push(`${failed} fejlet`);
if (skipped > 0) statusMessage.push(`${skipped} sprunget over`);
uploadStatus.textContent = `${statusMessage.join(', ')}`;
// Show detailed results
if (result.results && result.results.length > 0) {
console.log('📋 Detailed results:');
result.results.forEach(r => {
console.log(` ${r.status}: ${r.filename} - ${r.message}`);
if (r.status === 'success') {
console.log(` ✅ Email ID: ${r.email_id}, Subject: ${r.subject}`);
showNotification(`${r.filename} uploadet`, 'success');
} else if (r.status === 'duplicate') {
showNotification(` ${r.filename} findes allerede`, 'info');
} else if (r.status === 'error') {
showNotification(`${r.filename}: ${r.message}`, 'danger');
}
});
}
// Reload email list after successful uploads
if (uploaded > 0) {
console.log(`🔄 Reloading email list (${uploaded} new emails uploaded)`);
setTimeout(async () => {
await loadEmails();
await loadStats();
console.log('✅ Email list and stats reloaded');
uploadProgress.style.display = 'none';
fileInput.value = '';
toggleUploadZone(); // Close upload zone after successful upload
}, 2000);
} else {
console.log(' No new emails uploaded, not reloading');
setTimeout(() => {
uploadProgress.style.display = 'none';
fileInput.value = '';
}, 3000);
}
} catch (error) {
console.error('❌ Upload failed:', error);
uploadStatus.textContent = '❌ Upload fejlede';
progressBar.classList.add('bg-danger');
showNotification('Upload fejlede: ' + error.message, 'danger');
}
}
</script>
<!-- Quick Create Customer Modal -->
<div class="modal fade" id="quickCreateCustomerModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Opret Kunde</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="qcEmailId">
<div class="mb-3">
<label class="form-label fw-semibold">Navn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="qcCustomerName" placeholder="Firmanavn eller kontaktperson">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
<input type="email" class="form-control" id="qcCustomerEmail">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Email-domæne</label>
<input type="text" class="form-control" id="qcCustomerDomain" placeholder="firma.dk">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Telefon</label>
<input type="text" class="form-control" id="qcCustomerPhone">
</div>
<div id="qcCustomerStatus"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button class="btn btn-primary" onclick="submitQuickCustomer()">
<i class="bi bi-person-plus me-2"></i>Opret & Link
</button>
</div>
</div>
</div>
</div>
<!-- Quick Create Vendor Modal -->
<div class="modal fade" id="quickCreateVendorModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-shop me-2"></i>Opret Leverandør</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="qvEmailId">
<div id="qvAiStatus" class="mb-3"></div>
<div class="mb-3">
<label class="form-label fw-semibold">Navn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="qvVendorName" placeholder="Firmanavn">
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">CVR-nummer</label>
<input type="text" class="form-control" id="qvVendorCVR" maxlength="8" placeholder="12345678">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Telefon</label>
<input type="text" class="form-control" id="qvVendorPhone">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
<input type="email" class="form-control" id="qvVendorEmail">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Adresse</label>
<input type="text" class="form-control" id="qvVendorAddress" placeholder="Vejnavn 1, 1234 By">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Domæne</label>
<input type="text" class="form-control" id="qvVendorDomain" placeholder="firma.dk">
</div>
<div id="qvVendorStatus"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button class="btn btn-primary" onclick="submitQuickVendor()">
<i class="bi bi-shop me-2"></i>Opret & Link
</button>
</div>
</div>
</div>
</div>
{% endblock %}