bmc_hub/app/timetracking/frontend/orders.html
Christian acc78b03a3 UI: Vis Hub ordre ID i ordre detaljer efter eksport
- Tilføjet Hub ordre ID til success/info beskeder
- Viser at vTiger Timelog er opdateret med Hub ordre ID
- Gør det tydeligt at koblinger mellem Hub og vTiger er oprettet
2025-12-23 02:03:23 +01:00

532 lines
23 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

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

{% extends "shared/frontend/base.html" %}
{% block title %}Timetracking Ordrer - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Page specific styles */
.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>
{% endblock %}
{% block 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 (dynamisk) -->
<div class="row mb-4" id="safety-banner" style="display: none;">
<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>
let currentOrderId = null;
let orderModal = null;
// Initialize modal
document.addEventListener('DOMContentLoaded', async function() {
orderModal = new bootstrap.Modal(document.getElementById('orderModal'));
// Check if DRY-RUN mode is active
try {
const configResponse = await fetch('/api/v1/timetracking/config');
const config = await configResponse.json();
// Show banner only if DRY-RUN or READ-ONLY is enabled
if (config.economic_dry_run || config.economic_read_only) {
document.getElementById('safety-banner').style.display = 'block';
}
} catch (error) {
console.error('Error checking config:', error);
}
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 isLocked = order.status === 'exported';
const economicInfo = order.economic_draft_id
? `<br><small class="text-muted">e-conomic draft #${order.economic_draft_id}</small>`
: '';
return `
<tr class="order-row ${isLocked ? 'table-warning' : ''}" 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>
<button class="btn btn-sm btn-outline-danger"
onclick="event.stopPropagation(); cancelOrder(${order.id})">
<i class="bi bi-x-circle"></i> Annuller
</button>
` : ''}
${order.status === 'exported' ? `
<span class="badge bg-warning text-dark"><i class="bi bi-lock"></i> Låst</span>
<button class="btn btn-sm btn-outline-warning"
onclick="event.stopPropagation(); unlockOrder(${order.id})">
<i class="bi bi-unlock"></i> Lås op
</button>
` : ''}
</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>',
'exported': '<span class="badge bg-warning text-dark"><i class="bi bi-cloud-check"></i> Eksporteret</span>',
'draft': '<span class="badge bg-secondary">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>` : ''}
</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>Hub ordre ID: ${order.id} (opdateret i vTiger Timelog)
<br><small class="text-muted">Ordren er låst og kan ikke ændres. Tidsregistreringer i vTiger er markeret med Hub ordre ID.</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}` : ''}
<br>Hub ordre ID: ${order.id} (opdateret i vTiger Timelog)
</div>
` : ''}
`;
// Update export button
const exportBtn = document.getElementById('export-order-btn');
if (order.status === 'exported') {
exportBtn.disabled = true;
exportBtn.innerHTML = '<i class="bi bi-lock"></i> Eksporteret (Låst)';
exportBtn.classList.remove('btn-primary');
exportBtn.classList.add('btn-secondary');
} else if (order.status === 'draft') {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic';
exportBtn.onclick = exportCurrentOrder;
} else {
exportBtn.disabled = true;
exportBtn.innerHTML = `<i class="bi bi-x-circle"></i> ${order.status}`;
exportBtn.classList.remove('btn-primary');
exportBtn.classList.add('btn-secondary');
}
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);
}
}
// Unlock exported order (ADMIN)
async function unlockOrder(orderId) {
const adminCode = prompt(
'🔐 ADMIN ADGANG PÅKRÆVET\n\n' +
'Før ordren kan låses op skal den være slettet fra e-conomic.\n\n' +
'Indtast admin unlock kode:'
);
if (!adminCode) return;
if (!confirm(
'⚠️ ADVARSEL\n\n' +
'Er du SIKKER på at ordren er slettet fra e-conomic?\n\n' +
'Systemet vil tjekke om ordren stadig findes i e-conomic.\n\n' +
'Fortsæt?'
)) return;
try {
const response = await fetch(`/api/v1/timetracking/orders/${orderId}/unlock?admin_code=${encodeURIComponent(adminCode)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
alert(`❌ Kunne ikke låse ordre op:\n\n${errorData.detail}`);
return;
}
const result = await response.json();
alert(`✅ ${result.message}\n\nOrdren kan nu redigeres eller slettes.`);
loadOrders();
} catch (error) {
console.error('Error unlocking order:', error);
alert(`❌ Fejl ved unlock: ${error.message}`);
}
}
// Cancel order
async function cancelOrder(orderId) {
if (!confirm('Annuller denne ordre?\n\nTidsregistreringerne sættes tilbage til "godkendt" status.')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/orders/${orderId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json();
alert(`❌ Kunne ikke annullere ordre:\n\n${errorData.detail}`);
return;
}
alert('✅ Ordre annulleret\n\nTidsregistreringerne er sat tilbage til "godkendt" status.');
loadOrders();
} catch (error) {
console.error('Error cancelling order:', error);
alert(`❌ Fejl ved annullering: ${error.message}`);
}
}
</script>
</div>
{% endblock %}