bmc_hub/app/timetracking/frontend/orders.html
Christian 8791e34f4e feat: Implement email processing system with scheduler, fetching, classification, and rule matching
- Added EmailProcessorService to orchestrate email workflow: fetching, saving, classifying, and matching rules.
- Introduced EmailScheduler for background processing of emails every 5 minutes.
- Developed EmailService to handle email fetching from IMAP and Microsoft Graph API.
- Created database migration for email system, including tables for email messages, rules, attachments, and analysis.
- Implemented AI classification and extraction for invoices and time confirmations.
- Added logging for better traceability and error handling throughout the email processing pipeline.
2025-12-11 02:31:29 +01:00

592 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ordrer - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--success: #28a745;
--warning: #ffc107;
--danger: #dc3545;
--border-radius: 12px;
}
[data-theme="dark"] {
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--accent-light: #1e3a52;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
padding: 1rem 0;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
}
.nav-link:hover {
background-color: var(--accent-light);
color: var(--accent);
}
.nav-link.active {
background-color: var(--accent);
color: white;
font-weight: 600;
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
margin-bottom: 1.5rem;
}
.table {
background: var(--bg-card);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
border-bottom: 2px solid var(--accent-light);
}
.order-row {
cursor: pointer;
transition: background-color 0.2s;
}
.order-row:hover {
background-color: var(--accent-light);
}
.order-details {
background: var(--accent-light);
border-radius: var(--border-radius);
padding: 1.5rem;
margin-top: 1rem;
}
.line-item {
padding: 0.75rem;
background: var(--bg-card);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.modal-body .info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--accent-light);
}
.modal-body .info-row:last-child {
border-bottom: none;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-grid-3x3-gap-fill"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking">
<i class="bi bi-clock-history"></i> Oversigt
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/wizard">
<i class="bi bi-check-circle"></i> Godkend Tider
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/timetracking/customers">
<i class="bi bi-building"></i> Kunder & Priser
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/timetracking/orders">
<i class="bi bi-receipt"></i> Ordrer
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<button class="btn btn-link nav-link" onclick="toggleTheme()">
<i class="bi bi-moon-fill" id="theme-icon"></i>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="mb-1">
<i class="bi bi-receipt text-primary"></i> Ordrer
</h1>
<p class="text-muted mb-0">Oversigt over genererede ordrer og eksport til e-conomic</p>
</div>
<a href="/timetracking" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage
</a>
</div>
</div>
</div>
<!-- Safety Banner -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="bi bi-shield-exclamation me-2"></i>
<div>
<strong>DRY-RUN Mode Aktiv</strong> -
Eksport til e-conomic er i test-mode. Fakturaer oprettes ikke i e-conomic.
</div>
</div>
</div>
</div>
<!-- Orders Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Alle Ordrer</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadOrders()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
</div>
<div class="card-body">
<div id="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
<div id="orders-table" class="d-none">
<table class="table table-hover">
<thead>
<tr>
<th>Ordrenr.</th>
<th>Kunde</th>
<th>Dato</th>
<th class="text-center">Linjer</th>
<th class="text-end">Total</th>
<th class="text-center">Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="orders-tbody">
</tbody>
</table>
</div>
<div id="no-orders" class="text-center py-5 d-none">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Ingen ordrer endnu</p>
<a href="/timetracking" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> Godkend tider først
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Order Details Modal -->
<div class="modal fade" id="orderModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-receipt"></i> Ordre Detaljer
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="order-details-content">
<!-- Will be populated dynamically -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-success" id="export-order-btn" onclick="exportCurrentOrder()">
<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
let currentOrderId = null;
let orderModal = null;
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const icon = document.getElementById('theme-icon');
if (html.getAttribute('data-theme') === 'dark') {
html.removeAttribute('data-theme');
icon.className = 'bi bi-moon-fill';
localStorage.setItem('theme', 'light');
} else {
html.setAttribute('data-theme', 'dark');
icon.className = 'bi bi-sun-fill';
localStorage.setItem('theme', 'dark');
}
}
// Load saved theme
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-icon').className = 'bi bi-sun-fill';
}
// Initialize modal
document.addEventListener('DOMContentLoaded', function() {
orderModal = new bootstrap.Modal(document.getElementById('orderModal'));
loadOrders();
});
// Load all orders
async function loadOrders() {
document.getElementById('loading').classList.remove('d-none');
document.getElementById('orders-table').classList.add('d-none');
document.getElementById('no-orders').classList.add('d-none');
try {
const response = await fetch('/api/v1/timetracking/orders');
const orders = await response.json();
if (orders.length === 0) {
document.getElementById('loading').classList.add('d-none');
document.getElementById('no-orders').classList.remove('d-none');
return;
}
const tbody = document.getElementById('orders-tbody');
tbody.innerHTML = orders.map(order => {
const statusBadge = getStatusBadge(order);
const isPosted = order.status === 'posted';
const economicInfo = order.economic_order_number
? `<br><small class="text-muted">e-conomic #${order.economic_order_number}</small>`
: '';
return `
<tr class="order-row ${isPosted ? 'table-success' : ''}" onclick="viewOrder(${order.id})">
<td>
<strong>${order.order_number}</strong>
${economicInfo}
</td>
<td>${order.customer_name}</td>
<td>${new Date(order.order_date).toLocaleDateString('da-DK')}</td>
<td class="text-center">${order.line_count || 0}</td>
<td class="text-end"><strong>${parseFloat(order.total_amount).toFixed(2)} DKK</strong></td>
<td class="text-center">${statusBadge}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); viewOrder(${order.id})">
<i class="bi bi-eye"></i>
</button>
${order.status === 'draft' ? `
<button class="btn btn-sm btn-success"
onclick="event.stopPropagation(); exportOrder(${order.id})">
<i class="bi bi-cloud-upload"></i> Eksporter
</button>
` : ''}
${isPosted ? `
<span class="badge bg-success"><i class="bi bi-lock"></i> Låst</span>
` : ''}
</td>
</tr>
`;
}).join('');
document.getElementById('loading').classList.add('d-none');
document.getElementById('orders-table').classList.remove('d-none');
} catch (error) {
console.error('Error loading orders:', error);
document.getElementById('loading').innerHTML = `
<div class="alert alert-danger">
Fejl ved indlæsning: ${error.message}
</div>
`;
}
}
// Get status badge
function getStatusBadge(order) {
const statusMap = {
'cancelled': '<span class="badge bg-danger">Annulleret</span>',
'posted': '<span class="badge bg-success"><i class="bi bi-check-circle"></i> Bogført</span>',
'exported': '<span class="badge bg-info">Eksporteret</span>',
'draft': '<span class="badge bg-warning">Kladde</span>'
};
return statusMap[order.status] || '<span class="badge bg-secondary">Ukendt</span>';
}
// View order details
async function viewOrder(orderId) {
currentOrderId = orderId;
try {
const response = await fetch(`/api/v1/timetracking/orders/${orderId}`);
const order = await response.json();
const content = document.getElementById('order-details-content');
content.innerHTML = `
<div class="info-row">
<span class="fw-bold">Ordrenummer:</span>
<span>${order.order_number}</span>
</div>
<div class="info-row">
<span class="fw-bold">Kunde:</span>
<span>${order.customer_name}</span>
</div>
<div class="info-row">
<span class="fw-bold">Dato:</span>
<span>${new Date(order.order_date).toLocaleDateString('da-DK')}</span>
</div>
<div class="info-row">
<span class="fw-bold">Total:</span>
<span class="fs-5 fw-bold text-primary">${parseFloat(order.total_amount).toFixed(2)} DKK</span>
</div>
<hr class="my-3">
<h6 class="mb-3">Ordrelinjer:</h6>
${order.lines.map(line => {
// Parse data
const caseMatch = line.description.match(/CC(\d+)/);
const caseTitle = line.description.split(' - ').slice(1).join(' - ') || line.description;
const hours = parseFloat(line.quantity);
const unitPrice = parseFloat(line.unit_price);
const total = parseFloat(line.line_total);
const date = new Date(line.time_date).toLocaleDateString('da-DK');
// Extract contact name from case_contact if available
const contactName = line.case_contact || 'Ingen kontakt';
// Check if it's an on-site visit (udkørsel)
const isOnSite = line.description.toLowerCase().includes('udkørsel') ||
line.description.toLowerCase().includes('on-site');
return `
<div class="line-item mb-3 p-3" style="border: 1px solid #dee2e6; border-radius: 8px;">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
${caseMatch ? `<span class="badge bg-secondary">${caseMatch[0]}</span>` : ''}
<span class="fw-bold">${hours.toFixed(1)} timer</span>
<span class="text-muted">×</span>
<span>${unitPrice.toFixed(2)} DKK</span>
</div>
<div class="fw-bold text-uppercase mb-1" style="font-size: 0.95rem;">
${caseTitle}
</div>
<div class="text-muted small">
${date} - ${contactName}${isOnSite ? ' <span class="badge bg-info">Udkørsel</span>' : ''}
</div>
</div>
<div class="text-end">
<div class="fs-5 fw-bold text-primary">${total.toFixed(2)} DKK</div>
</div>
</div>
</div>
`;
}).join('')}
${order.status === 'posted' ? `
<div class="alert alert-success mt-3 mb-0">
<i class="bi bi-lock-fill"></i>
<strong>Bogført til e-conomic</strong> den ${new Date(order.exported_at).toLocaleDateString('da-DK')}
<br>e-conomic ordre nr.: ${order.economic_order_number}
<br><small class="text-muted">Ordren er låst og kan ikke ændres.</small>
</div>
` : order.economic_draft_id ? `
<div class="alert alert-info mt-3 mb-0">
<i class="bi bi-check-circle"></i>
Eksporteret til e-conomic den ${new Date(order.exported_at).toLocaleDateString('da-DK')}
<br>Draft Order nr.: ${order.economic_draft_id}
${order.economic_order_number ? `<br>e-conomic ordre nr.: ${order.economic_order_number}` : ''}
</div>
` : ''}
`;
// Update export button
const exportBtn = document.getElementById('export-order-btn');
if (order.status === 'posted') {
exportBtn.disabled = true;
exportBtn.innerHTML = '<i class="bi bi-lock"></i> Bogført (Låst)';
exportBtn.classList.remove('btn-primary');
exportBtn.classList.add('btn-secondary');
} else if (order.economic_draft_id) {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Re-eksporter (force)';
exportBtn.onclick = () => {
if (confirm('Re-eksporter ordre til e-conomic?\n\nDette vil overskrive den eksisterende draft order.')) {
exportOrderForce(currentOrderId);
}
};
} else {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic';
exportBtn.onclick = exportCurrentOrder;
}
orderModal.show();
} catch (error) {
alert('Fejl ved indlæsning af ordre: ' + error.message);
}
}
// Export order
async function exportOrder(orderId) {
if (!confirm('Eksporter ordre til e-conomic?\n\nDette opretter en kladde-ordre i e-conomic.')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_id: orderId,
force: false
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Export failed');
}
const result = await response.json();
if (result.dry_run) {
alert(`DRY-RUN MODE:\n\n${result.message}\n\nDetails:\n- Ordre: ${result.details.order_number}\n- Kunde: ${result.details.customer_name}\n- Total: ${result.details.total_amount} DKK\n- Linjer: ${result.details.line_count}\n\n⚠️ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`);
} else if (result.success) {
alert(`✅ Ordre eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}\n\n${result.message}`);
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
}
} else {
throw new Error(result.message || 'Export failed');
}
} catch (error) {
alert('Fejl ved eksport: ' + error.message);
}
}
// Export current order from modal
function exportCurrentOrder() {
if (currentOrderId) {
exportOrder(currentOrderId);
}
}
// Force re-export order
async function exportOrderForce(orderId) {
try {
const response = await fetch(`/api/v1/timetracking/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_id: orderId,
force: true
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Export failed');
}
const result = await response.json();
if (result.dry_run) {
alert(`DRY-RUN MODE:\n\n${result.message}\n\n⚠️ Ingen ændringer er foretaget i e-conomic (DRY-RUN mode aktiveret).`);
} else if (result.success) {
alert(`✅ Ordre re-eksporteret til e-conomic!\n\n- Draft Order nr.: ${result.economic_draft_id}\n- e-conomic ordre nr.: ${result.economic_order_number}`);
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
}
} else {
throw new Error(result.message || 'Export failed');
}
} catch (error) {
alert('Fejl ved eksport: ' + error.message);
}
}
</script>
</body>
</html>