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