2025-12-11 12:45:29 +01:00
{% 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;
}
/* 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;
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);
}
.email-content-subject {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
}
.email-content-meta {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.sender-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.sender-details {
display: flex;
flex-direction: column;
}
.sender-name {
font-weight: 600;
font-size: 0.95rem;
}
.sender-email {
font-size: 0.8rem;
color: var(--text-secondary);
}
.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);
2025-12-11 12:57:14 +01:00
flex-wrap: wrap;
2025-12-11 12:45:29 +01:00
}
.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;
line-height: 1.6;
}
.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);
}
.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;
}
.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;
}
/* 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;
}
2025-12-15 12:28:12 +01:00
/* 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;
}
2025-12-11 12:45:29 +01:00
.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;
}
2025-12-15 12:28:12 +01:00
/* 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;
}
2026-01-06 15:11:28 +01:00
/* 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);
}
2025-12-11 12:45:29 +01:00
< / 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" >
2025-12-15 12:28:12 +01:00
< 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 >
2025-12-11 12:45:29 +01:00
< / div >
2026-01-06 15:11:28 +01:00
<!-- 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 >
2025-12-11 12:45:29 +01:00
< div class = "email-list-filters" >
2025-12-15 12:28:12 +01:00
< 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')" >
2025-12-11 12:45:29 +01:00
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 >
2025-12-15 12:28:12 +01:00
< button class = "filter-pill" data-filter = "processed" onclick = "setFilter('processed')" >
Behandlet < span class = "count" id = "countProcessed" > 0< / span >
< / button >
2025-12-11 12:45:29 +01:00
< 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 >
2026-01-11 19:23:21 +01:00
< button class = "filter-pill" data-filter = "newsletter" onclick = "setFilter('newsletter')" >
Info/Nyhed < span class = "count" id = "countNewsletter" > 0< / span >
< / button >
2025-12-11 12:45:29 +01:00
< 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 >
2025-12-15 12:28:12 +01:00
<!-- Right Sidebar - AI Analysis + Activity Log -->
2025-12-11 12:45:29 +01:00
< div class = "email-analysis-sidebar" id = "emailAnalysisSidebar" >
2025-12-15 12:28:12 +01:00
< 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 >
2025-12-11 12:45:29 +01:00
< / 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 >
2025-12-15 12:28:12 +01:00
<!-- 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 >
2025-12-11 12:45:29 +01:00
{% endblock %}
{% block extra_js %}
< script >
console.log('🚀 Email UI JavaScript loaded');
// State Management
2025-12-15 12:28:12 +01:00
let currentFilter = 'active'; // Default to active (unprocessed) emails
2025-12-11 12:45:29 +01:00
let currentEmailId = null;
let emails = [];
let selectedEmails = new Set();
let emailSearchTimeout = null;
let autoRefreshInterval = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('📧 Email UI: DOMContentLoaded fired');
console.log('Email list element:', document.getElementById('emailListBody'));
loadEmails();
loadStats();
setupEventListeners();
setupKeyboardShortcuts();
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' });
}
}
2025-12-15 12:28:12 +01:00
let isLoadingEmails = false;
2025-12-11 12:45:29 +01:00
async function loadEmails(searchQuery = '') {
2025-12-15 12:28:12 +01:00
// Prevent duplicate calls
if (isLoadingEmails) {
console.log('⏭️ Already loading emails, skipping...');
return;
}
2025-12-11 12:45:29 +01:00
try {
2025-12-15 12:28:12 +01:00
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 > ';
}
2025-12-11 12:45:29 +01:00
let url = '/api/v1/emails?limit=100';
2025-12-15 12:28:12 +01:00
// Handle special filters
if (currentFilter === 'active') {
// Show only new, error, or flagged (pending review) emails
2026-01-11 19:23:21 +01:00
// If searching, ignore status filter to allow global search
if (!searchQuery) {
url += '&status=new';
}
2025-12-15 12:28:12 +01:00
} else if (currentFilter === 'processed') {
url += '&status=processed';
} else if (currentFilter !== 'all') {
// Classification filter
2025-12-11 12:45:29 +01:00
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();
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);
2025-12-15 12:28:12 +01:00
// 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;
2025-12-11 12:45:29 +01:00
}
}
function renderEmailList(emailList) {
const tbody = document.getElementById('emailListBody');
2025-12-15 12:28:12 +01:00
if (!tbody) {
console.error('❌ emailListBody element not found!');
return;
}
console.log('Rendering email list:', emailList ? emailList.length : 0, 'emails');
2025-12-11 12:45:29 +01:00
if (!emailList || emailList.length === 0) {
tbody.innerHTML = `
< div class = "empty-state" >
< i class = "bi bi-inbox" > < / i >
< p > Ingen emails fundet< / p >
2025-12-15 12:28:12 +01:00
< small class = "text-muted" > Filter: ${currentFilter}< / small >
2025-12-11 12:45:29 +01:00
< / 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 >
2025-12-15 12:28:12 +01:00
${getStatusBadge(email)}
2025-12-11 12:45:29 +01:00
${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);
2026-01-25 03:29:28 +01:00
const errorMsg = error?.message || String(error) || 'Ukendt fejl';
alert('Kunne ikke indlæse email detaljer: ' + errorMsg);
showEmptyState();
2025-12-11 12:45:29 +01:00
}
}
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 >
2025-12-11 12:57:14 +01:00
< 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 >
2025-12-15 12:28:12 +01:00
< 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 >
2025-12-11 12:57:14 +01:00
< 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" 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"
download="${att.filename}"
title="Download ${att.filename}">
< i class = "bi bi-download me-1" > < / i > ${att.filename}
< / a >
`}
`;
}).join('')}
< / div >
` : ''}
2025-12-11 12:45:29 +01:00
< / div >
< div class = "email-body" >
${email.body_html ? `< iframe srcdoc = "${email.body_html.replace(/" / g , ' & quot ; ' ) } " > < / iframe > ` :
`< pre style = "white-space: pre-wrap; font-family: inherit;" > ${escapeHtml(email.body_text || 'Ingen indhold')}< / pre > `}
< / div >
`;
}
function renderEmailAnalysis(email) {
2025-12-15 12:28:12 +01:00
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
2026-01-25 03:29:28 +01:00
if (!aiAnalysisTab) {
console.error('aiAnalysisTab element not found in DOM');
return;
}
2025-12-11 12:45:29 +01:00
const classification = email.classification || 'general';
const confidence = email.confidence_score || 0;
2025-12-15 12:28:12 +01:00
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar
aiAnalysisTab.innerHTML = `
2025-12-11 12:45:29 +01:00
${getClassificationActions(email) ? `
< div class = "analysis-card" >
< h6 > < i class = "bi bi-lightning-charge-fill me-2" > < / i > Hurtig Action< / h6 >
< div class = "d-flex flex-column gap-2" >
${getClassificationActions(email)}
< / 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 >
2026-01-25 03:29:28 +01:00
< option value = "recording" $ { classification = == ' recording ' ? ' selected ' : ' ' } > 🎤 Optagelse< / option >
2025-12-11 12:45:29 +01:00
< 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 >
< div class = "analysis-card" >
< h6 > < i class = "bi bi-info-circle me-2" > < / i > Metadata< / h6 >
< ul class = "metadata-list" >
< li class = "metadata-item" >
< div class = "metadata-label" > Message ID< / div >
< div class = "metadata-value" style = "font-size: 0.7rem; word-break: break-all;" > ${email.message_id || 'N/A'}< / div >
< / li >
< li class = "metadata-item" >
< div class = "metadata-label" > Modtaget< / div >
< div class = "metadata-value" > ${formatDateTime(email.received_date)}< / div >
< / li >
< li class = "metadata-item" >
< div class = "metadata-label" > Status< / div >
< div class = "metadata-value" > ${email.status || 'new'}< / div >
< / li >
${email.extracted_invoice_number ? `
< li class = "metadata-item" >
< div class = "metadata-label" > Fakturanummer< / div >
< div class = "metadata-value" > ${email.extracted_invoice_number}< / div >
< / li >
` : ''}
${email.extracted_amount ? `
< li class = "metadata-item" >
< div class = "metadata-label" > Beløb< / div >
< div class = "metadata-value" > ${email.extracted_amount} ${email.extracted_currency || 'DKK'}< / div >
< / li >
` : ''}
< / ul >
< / div >
${email.matched_rules & & email.matched_rules.length > 0 ? `
< div class = "analysis-card" >
< h6 > < i class = "bi bi-lightning-charge me-2" > < / i > Matchede Regler< / h6 >
< div class = "rules-indicator" >
< i class = "bi bi-check-circle-fill" > < / i >
< span > ${email.matched_rules.length} regel(er) matchet< / span >
< / div >
< / div >
` : ''}
`;
}
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 >
`;
2025-12-15 12:28:12 +01:00
// 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 > ';
}
2025-12-11 12:45:29 +01:00
}
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();
2025-12-15 12:28:12 +01:00
// Calculate active emails (new + error + flagged)
const activeCount = stats.new_emails || 0;
document.getElementById('countActive').textContent = activeCount;
2025-12-11 12:45:29 +01:00
document.getElementById('countAll').textContent = stats.total_emails || 0;
2025-12-15 12:28:12 +01:00
document.getElementById('countProcessed').textContent = stats.processed_emails || 0;
2025-12-11 12:45:29 +01:00
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;
2026-01-11 19:23:21 +01:00
document.getElementById('countNewsletter').textContent = stats.newsletters || 0;
document.getElementById('countGeneral').textContent = stats.total_emails - stats.invoices - stats.time_confirmations - (stats.newsletters || 0) || 0;
2025-12-11 12:45:29 +01:00
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);
}
}
2025-12-15 12:28:12 +01:00
// ========== 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;
}
2025-12-11 12:45:29 +01:00
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 {
2025-12-15 12:28:12 +01:00
console.log('📧 Behandler email...');
// Get email data with attachments
2025-12-11 12:45:29 +01:00
const response = await fetch(`/api/v1/emails/${emailId}`);
if (!response.ok) throw new Error('Failed to fetch email');
const email = await response.json();
2025-12-15 12:28:12 +01:00
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;
}
2025-12-11 12:45:29 +01:00
2025-12-15 12:28:12 +01:00
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');
}
2025-12-11 12:45:29 +01:00
} catch (error) {
2025-12-15 12:28:12 +01:00
console.error('Error creating supplier invoice:', error);
showError('Kunne ikke behandle email: ' + error.message);
2025-12-11 12:45:29 +01:00
}
}
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) {
showError('Sags-modul er ikke implementeret endnu');
}
async function linkToCustomer(emailId) {
showError('Kunde-linking er ikke implementeret endnu');
}
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;
}
2025-12-15 12:28:12 +01:00
function getStatusBadge(email) {
const status = email.status || 'new';
const approvalStatus = email.approval_status;
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) {
2025-12-11 12:45:29 +01:00
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);
}
2025-12-15 12:28:12 +01:00
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
}
2025-12-11 12:45:29 +01:00
function showError(message) {
alert('Fejl: ' + message);
}
window.addEventListener('beforeunload', () => {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
});
2025-12-15 12:28:12 +01:00
// ========== 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, '" ')}"
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);
}
2026-01-06 15:11:28 +01:00
// 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');
}
}
2025-12-11 12:45:29 +01:00
< / script >
{% endblock %}