2026-02-17 08:29:05 +01:00
{% extends "shared/frontend/base.html" %}
{% block title %}Ordre #{{ draft_id }} - BMC Hub{% endblock %}
{% block extra_css %}
< style >
.ordre-header {
background: var(--bg-card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(0,0,0,0.06);
}
.info-item {
margin-bottom: 0.75rem;
}
.info-label {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.info-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.summary-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
}
.summary-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.6px;
}
.summary-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
}
.table thead th {
font-size: 0.8rem;
text-transform: uppercase;
color: white;
background: var(--accent);
}
2026-03-23 20:35:15 +01:00
.sync-card {
background: var(--bg-card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0,0,0,0.06);
margin-bottom: 1rem;
}
.sync-label {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.sync-value {
font-weight: 600;
color: var(--text-primary);
}
.event-payload {
max-width: 360px;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.8rem;
}
2026-02-17 08:29:05 +01:00
< / style >
{% endblock %}
{% block content %}
< div class = "container-fluid py-4" >
< div class = "d-flex flex-wrap justify-content-between align-items-center mb-3" >
< div >
< h2 class = "mb-1" > < i class = "bi bi-receipt me-2" > < / i > Ordre #{{ draft_id }}< / h2 >
< div class = "text-muted" > Detaljeret visning< / div >
< / div >
< div class = "d-flex gap-2" >
< a href = "/ordre" class = "btn btn-outline-secondary" > < i class = "bi bi-arrow-left me-1" > < / i > Tilbage til liste< / a >
< button class = "btn btn-success" onclick = "addManualLine()" > < i class = "bi bi-plus-circle me-1" > < / i > Tilføj linje< / button >
< button class = "btn btn-primary" onclick = "saveOrder()" > < i class = "bi bi-save me-1" > < / i > Gem< / button >
< button class = "btn btn-warning" onclick = "exportOrder()" > < i class = "bi bi-cloud-upload me-1" > < / i > Eksporter til e-conomic< / button >
2026-09-13 02:30:52 +02:00
< button class = "btn btn-outline-primary" onclick = "previewPrices()" > Kontrollér nye priser< / button >
< a class = "btn btn-outline-primary" href = "/settings/economic-products" > Varekoblinger og eksportfejl< / a >
2026-02-17 08:29:05 +01:00
< / div >
< / div >
< div id = "safetyBanner" class = "alert alert-warning d-none" >
< i class = "bi bi-shield-exclamation me-1" > < / i >
< strong > Safety mode aktiv:< / strong > e-conomic eksport er read-only eller dry-run.
< / div >
2026-09-13 11:41:50 +02:00
< div id = "exportErrorBanner" class = "alert alert-danger d-none" > < / div >
2026-02-17 08:29:05 +01:00
2026-09-13 11:41:50 +02:00
< div id = "forceExportWrap" class = "d-flex justify-content-end mb-3" >
2026-03-23 20:35:15 +01:00
< div class = "form-check form-switch" >
< input class = "form-check-input" type = "checkbox" id = "forceExportToggle" >
< label class = "form-check-label" for = "forceExportToggle" > Force export (brug kun ved retry)< / label >
< / div >
< / div >
2026-02-17 08:29:05 +01:00
< div class = "ordre-header" >
< div class = "row g-3" >
< div class = "col-md-3" >
< div class = "info-item" >
< div class = "info-label" > Titel< / div >
< input type = "text" id = "orderTitle" class = "form-control" placeholder = "Ordre titel" >
< / div >
< / div >
< div class = "col-md-3" >
< div class = "info-item" >
< div class = "info-label" > Kunde ID< / div >
< input type = "number" id = "customerId" class = "form-control" placeholder = "Kunde ID" >
2026-09-13 11:41:50 +02:00
< div id = "customerIdentity" class = "small text-muted mt-1" > -< / div >
2026-02-17 08:29:05 +01:00
< / div >
< / div >
< div class = "col-md-3" >
< div class = "info-item" >
< div class = "info-label" > Layout nr.< / div >
< input type = "number" id = "layoutNumber" class = "form-control" placeholder = "e-conomic layout" >
< / div >
< / div >
< div class = "col-md-3" >
< div class = "info-item" >
< div class = "info-label" > Status< / div >
< div id = "orderStatus" class = "info-value" > -< / div >
< / div >
< / div >
< div class = "col-12" >
< div class = "info-item" >
< div class = "info-label" > Noter< / div >
< textarea id = "orderNotes" class = "form-control" rows = "2" placeholder = "Valgfri noter til ordren" > < / textarea >
< / div >
< / div >
< / div >
< / div >
< div class = "row g-3 mb-3" >
< div class = "col-md-3" > < div class = "summary-card" > < div class = "summary-title" > Antal linjer< / div > < div id = "sumLines" class = "summary-value" > 0< / div > < / div > < / div >
< div class = "col-md-3" > < div class = "summary-card" > < div class = "summary-title" > Total beløb< / div > < div id = "sumAmount" class = "summary-value" > 0 kr.< / div > < / div > < / div >
< div class = "col-md-3" > < div class = "summary-card" > < div class = "summary-title" > Oprettet< / div > < div id = "createdAt" class = "summary-value" > -< / div > < / div > < / div >
< div class = "col-md-3" > < div class = "summary-card" > < div class = "summary-title" > Sidst opdateret< / div > < div id = "updatedAt" class = "summary-value" > -< / div > < / div > < / div >
< / div >
2026-09-13 11:41:50 +02:00
< div id = "exportReceiptCard" class = "card mb-3 d-none" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< strong > < i class = "bi bi-receipt-cutoff me-1" > < / i > Eksportkvittering< / strong >
< a id = "economicOrderLink" class = "btn btn-sm btn-outline-primary d-none" target = "_blank" rel = "noopener" > Åbn i e-conomic< / a >
< / div >
< div class = "card-body" > < div class = "row g-3" >
< div class = "col-md-3" > < div class = "small text-muted" > Ordrenummer< / div > < div id = "receiptOrderNumber" class = "fw-semibold" > -< / div > < / div >
< div class = "col-md-3" > < div class = "small text-muted" > e-conomic-kunde< / div > < div id = "receiptCustomer" > -< / div > < / div >
< div class = "col-md-2" > < div class = "small text-muted" > Periode< / div > < div id = "receiptPeriod" > -< / div > < / div >
< div class = "col-md-2" > < div class = "small text-muted" > Valuta / layout< / div > < div id = "receiptCurrencyLayout" > -< / div > < / div >
< div class = "col-md-2" > < div class = "small text-muted" > Eksporteret af< / div > < div id = "receiptUser" > -< / div > < / div >
< div class = "col-md-3" > < div class = "small text-muted" > Eksporteret< / div > < div id = "receiptExportedAt" > -< / div > < / div >
< div class = "col-md-3" > < div class = "small text-muted" > Nettobeløb< / div > < div id = "receiptNetAmount" > -< / div > < / div >
< div class = "col-md-6" > < div class = "small text-muted" > Intern eksportreference< / div > < div id = "receiptExportId" class = "font-monospace small text-break" > -< / div > < / div >
< div id = "receiptProductsWrap" class = "col-12 d-none" > < div class = "small text-muted" > Oprettede varer< / div > < div id = "receiptProducts" > -< / div > < / div >
< / div > < / div >
< / div >
2026-03-23 20:35:15 +01:00
< div class = "sync-card" >
< div class = "d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3" >
< div >
2026-09-13 11:41:50 +02:00
< h5 class = "mb-1" > < i class = "bi bi-arrow-repeat me-2" > < / i > Eksport og status< / h5 >
< div class = "text-muted small" > Status, e-conomic-numre og historik for ordren< / div >
2026-03-23 20:35:15 +01:00
< / div >
< div class = "d-flex gap-2" >
< button class = "btn btn-outline-secondary btn-sm" onclick = "loadSyncEvents(0)" >
< i class = "bi bi-arrow-clockwise me-1" > < / i > Opdater events
< / button >
< button class = "btn btn-outline-success btn-sm" onclick = "markDraftPaid()" >
< i class = "bi bi-cash-coin me-1" > < / i > Markér som betalt
< / button >
< / div >
< / div >
< div class = "row g-3 align-items-end mb-3" >
< div class = "col-md-3" >
< div class = "sync-label" > Sync status< / div >
< select id = "syncStatusSelect" class = "form-select form-select-sm" >
< option value = "pending" > pending< / option >
2026-09-13 11:41:50 +02:00
< option value = "uncertain" > uncertain< / option >
2026-03-23 20:35:15 +01:00
< option value = "exported" > exported< / option >
< option value = "failed" > failed< / option >
< option value = "posted" > posted< / option >
< option value = "paid" > paid< / option >
< / select >
< / div >
< div class = "col-md-3" >
< div class = "sync-label" > e-conomic ordre nr.< / div >
< input id = "economicOrderNumber" type = "text" class = "form-control form-control-sm" placeholder = "fx 12345" >
< / div >
2026-09-13 11:41:50 +02:00
< div class = "col-md-3" id = "invoiceNumberEditWrap" >
2026-03-23 20:35:15 +01:00
< div class = "sync-label" > e-conomic faktura nr.< / div >
< input id = "economicInvoiceNumber" type = "text" class = "form-control form-control-sm" placeholder = "fx 998877" >
< / div >
< div class = "col-md-3 d-grid" >
< button class = "btn btn-primary btn-sm" onclick = "updateSyncStatus()" >
< i class = "bi bi-check2-circle me-1" > < / i > Gem sync status
< / button >
< / div >
< / div >
< div class = "row g-3 mb-3" >
< div class = "col-md-3" >
< div class = "sync-label" > Aktuel status< / div >
< div id = "syncStatusBadge" class = "sync-value" > -< / div >
< / div >
< div class = "col-md-3" >
< div class = "sync-label" > Sidste sync< / div >
< div id = "lastSyncAt" class = "sync-value" > -< / div >
< / div >
< div class = "col-md-3" >
< div class = "sync-label" > Ordrenummer< / div >
< div id = "economicOrderNumberView" class = "sync-value" > -< / div >
< / div >
2026-09-13 11:41:50 +02:00
< div class = "col-md-3" id = "invoiceNumberViewWrap" >
2026-03-23 20:35:15 +01:00
< div class = "sync-label" > Fakturanummer< / div >
< div id = "economicInvoiceNumberView" class = "sync-value" > -< / div >
< / div >
< / div >
< div class = "row g-2 align-items-end mb-3" >
< div class = "col-md-3" >
2026-09-13 11:41:50 +02:00
< label class = "form-label mb-1" > Hændelse< / label >
2026-03-23 20:35:15 +01:00
< input id = "eventTypeFilter" type = "text" class = "form-control form-control-sm" placeholder = "fx export_success" >
< / div >
< div class = "col-md-2" >
< label class = "form-label mb-1" > Fra status< / label >
< input id = "fromStatusFilter" type = "text" class = "form-control form-control-sm" placeholder = "fx pending" >
< / div >
< div class = "col-md-2" >
< label class = "form-label mb-1" > Til status< / label >
< input id = "toStatusFilter" type = "text" class = "form-control form-control-sm" placeholder = "fx exported" >
< / div >
< div class = "col-md-2" >
< label class = "form-label mb-1" > Fra dato< / label >
< input id = "fromDateFilter" type = "date" class = "form-control form-control-sm" >
< / div >
< div class = "col-md-2" >
< label class = "form-label mb-1" > Til dato< / label >
< input id = "toDateFilter" type = "date" class = "form-control form-control-sm" >
< / div >
< div class = "col-md-1 d-grid" >
< button class = "btn btn-outline-primary btn-sm" onclick = "loadSyncEvents(0)" >
< i class = "bi bi-funnel" > < / i >
< / button >
< / div >
< / div >
< div class = "table-responsive" >
< table class = "table table-sm align-middle mb-2" >
< thead >
< tr >
< th > Tidspunkt< / th >
< th > Type< / th >
< th > Fra< / th >
< th > Til< / th >
< th > Payload< / th >
< / tr >
< / thead >
< tbody id = "syncEventsBody" >
< tr > < td colspan = "5" class = "text-muted text-center py-3" > Indlæser events...< / td > < / tr >
< / tbody >
< / table >
< / div >
< div class = "d-flex justify-content-between align-items-center" >
< div id = "syncEventsMeta" class = "small text-muted" > -< / div >
< div class = "d-flex gap-2" >
< button id = "eventsPrevBtn" class = "btn btn-outline-secondary btn-sm" onclick = "changeEventsPage(-1)" > Forrige< / button >
< button id = "eventsNextBtn" class = "btn btn-outline-secondary btn-sm" onclick = "changeEventsPage(1)" > Næste< / button >
< / div >
< / div >
< / div >
2026-02-17 08:29:05 +01:00
< div class = "card" >
< div class = "card-body" >
< div class = "table-responsive" >
< table class = "table table-hover align-middle" >
< thead >
< tr >
< th > Kilde< / th >
< th > Beskrivelse< / th >
< th > Antal< / th >
< th > Enhedspris< / th >
< th > Rabat %< / th >
< th > Beløb< / th >
< th > Enhed< / th >
< th > Status< / th >
< th > Handling< / th >
< / tr >
< / thead >
< tbody id = "linesTableBody" >
< tr > < td colspan = "9" class = "text-muted text-center py-4" > Indlæser...< / td > < / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
2026-03-23 20:35:15 +01:00
< div class = "toast-container position-fixed bottom-0 end-0 p-3" style = "z-index: 1080;" >
< div id = "detailToast" class = "toast align-items-center text-bg-dark border-0" role = "alert" aria-live = "assertive" aria-atomic = "true" >
< div class = "d-flex" >
< div class = "toast-body" id = "detailToastBody" > -< / div >
< button type = "button" class = "btn-close btn-close-white me-2 m-auto" data-bs-dismiss = "toast" aria-label = "Close" > < / button >
< / div >
< / div >
< / div >
2026-09-13 09:56:24 +02:00
< div class = "modal fade" id = "economicProductPreviewModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-xl modal-dialog-scrollable" >
< div class = "modal-content" >
< div class = "modal-header" >
< div >
< h5 class = "modal-title" > Varer oprettes før eksport< / h5 >
< div class = "text-muted small" > Kontrollér værdierne. Først efter godkendelse oprettes varerne og ordrekladden.< / div >
< / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" aria-label = "Luk" > < / button >
< / div >
< div class = "modal-body" >
< div class = "alert alert-info" > < i class = "bi bi-info-circle me-1" > < / i > Der skrives ikke noget til e-conomic, før du vælger < strong > Opret varer og eksportér< / strong > .< / div >
< div id = "economicProductPreviewRows" > < / div >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-outline-secondary" data-bs-dismiss = "modal" > Annuller< / button >
< button type = "button" class = "btn btn-primary" id = "confirmEconomicProductsBtn" onclick = "confirmEconomicProductCreation()" >
< i class = "bi bi-box-arrow-up-right me-1" > < / i > Opret varer og eksportér
< / button >
< / div >
< / div >
< / div >
< / div >
2026-02-17 08:29:05 +01:00
< / div >
{% endblock %}
{% block extra_js %}
< script >
const draftId = {{ draft_id }};
let orderData = null;
let orderLines = [];
2026-03-23 20:35:15 +01:00
const syncEventsLimit = 10;
let syncEventsOffset = 0;
let syncEventsTotal = 0;
let detailToast = null;
2026-09-13 09:56:24 +02:00
let pendingEconomicProductPreview = null;
2026-03-23 20:35:15 +01:00
function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast');
const bodyEl = document.getElementById('detailToastBody');
if (!toastEl || !bodyEl || typeof bootstrap === 'undefined') {
console.log(message);
return;
}
toastEl.className = 'toast align-items-center border-0';
toastEl.classList.add(`text-bg-${variant}`);
bodyEl.textContent = message;
if (!detailToast) {
detailToast = new bootstrap.Toast(toastEl, { delay: 3200 });
}
detailToast.show();
}
2026-02-17 08:29:05 +01:00
2026-09-13 11:41:50 +02:00
function formatCurrency(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
2026-02-17 08:29:05 +01:00
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function sourceBadge(type) {
if (type === 'subscription') return '< span class = "badge bg-primary" > Abonnement< / span > ';
if (type === 'hardware') return '< span class = "badge bg-secondary" > Hardware< / span > ';
if (type === 'manual') return '< span class = "badge bg-info" > Manuel< / span > ';
return '< span class = "badge bg-success" > Salg< / span > ';
}
2026-03-23 20:35:15 +01:00
function escapeHtml(value) {
return String(value || '')
.replace(/& /g, '& ')
.replace(/< /g, '< ')
.replace(/>/g, '> ')
.replace(/"/g, '" ')
.replace(/'/g, '' ');
}
function syncStatusBadge(status) {
const normalized = String(status || 'pending').toLowerCase();
if (normalized === 'paid') return '< span class = "badge bg-success" > paid< / span > ';
if (normalized === 'posted') return '< span class = "badge bg-info text-dark" > posted< / span > ';
if (normalized === 'exported') return '< span class = "badge bg-primary" > exported< / span > ';
if (normalized === 'failed') return '< span class = "badge bg-danger" > failed< / span > ';
2026-09-13 11:41:50 +02:00
if (normalized === 'uncertain') return '< span class = "badge bg-warning text-dark" > uafklaret< / span > ';
2026-03-23 20:35:15 +01:00
return '< span class = "badge bg-warning text-dark" > pending< / span > ';
}
function refreshSyncPanelFromOrder() {
document.getElementById('syncStatusSelect').value = (orderData.sync_status || 'pending').toLowerCase();
document.getElementById('economicOrderNumber').value = orderData.economic_order_number || '';
document.getElementById('economicInvoiceNumber').value = orderData.economic_invoice_number || '';
document.getElementById('syncStatusBadge').innerHTML = syncStatusBadge(orderData.sync_status);
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
2026-09-13 11:41:50 +02:00
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
if (hasReceipt) {
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
const period = orderData.coverage_start & & orderData.coverage_end
? `${orderData.coverage_start} – ${orderData.coverage_end}`
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
document.getElementById('receiptPeriod').textContent = period;
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
const link = document.getElementById('economicOrderLink');
link.classList.toggle('d-none', !orderData.economic_order_url);
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
}
2026-03-23 20:35:15 +01:00
}
2026-02-17 08:29:05 +01:00
function renderLines() {
const tbody = document.getElementById('linesTableBody');
if (!orderLines.length) {
tbody.innerHTML = '< tr > < td colspan = "9" class = "text-muted text-center py-4" > Ingen linjer< / td > < / tr > ';
updateSummary();
return;
}
tbody.innerHTML = orderLines.map((line, index) => {
2026-05-06 07:01:43 +02:00
const isExportedLine = line.export_status === 'exported';
const descriptionField = `< input type = "text" class = "form-control form-control-sm" value = "${escapeHtml(line.description || '')}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">`;
2026-02-17 08:29:05 +01:00
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
? '< span class = "badge bg-success" > Eksporteret< / span > '
: exportStatus === 'dry-run'
? '< span class = "badge bg-warning text-dark" > Dry-run< / span > '
: '< span class = "badge bg-light text-dark border" > Ikke eksporteret< / span > ';
2026-05-06 07:01:43 +02:00
const lineActions = isExportedLine
? '-'
: `
< div class = "btn-group btn-group-sm" role = "group" >
< button class = "btn btn-sm btn-outline-primary" onclick = "resolveDetailLineProductByCode(${index})" title = "Søg produkt via strengkode i APIGateway" >
< i class = "bi bi-upc-scan" > < / i >
< / button >
< button class = "btn btn-sm btn-outline-danger" onclick = "deleteLine(${index})" title = "Slet linje" > < i class = "bi bi-trash" > < / i > < / button >
< / div >
`;
2026-02-17 08:29:05 +01:00
return `
< tr >
< td > ${sourceBadge(line.source_type)}< / td >
< td > ${descriptionField}< / td >
< td style = "min-width:100px;" >
< input type = "number" min = "0.01" step = "0.01" class = "form-control form-control-sm" value = "${Number(line.quantity || 1)}"
2026-05-06 07:01:43 +02:00
${isExportedLine ? 'disabled' : ''}
2026-02-17 08:29:05 +01:00
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
< / td >
< td style = "min-width:120px;" >
< input type = "number" min = "0" step = "0.01" class = "form-control form-control-sm" value = "${Number(line.unit_price || 0)}"
2026-05-06 07:01:43 +02:00
${isExportedLine ? 'disabled' : ''}
2026-02-17 08:29:05 +01:00
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
< / td >
< td style = "min-width:110px;" >
< input type = "number" min = "0" max = "100" step = "0.01" class = "form-control form-control-sm" value = "${Number(line.discount_percentage || 0)}"
2026-05-06 07:01:43 +02:00
${isExportedLine ? 'disabled' : ''}
2026-02-17 08:29:05 +01:00
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
< / td >
< td id = "lineAmount-${index}" class = "fw-semibold" > ${formatCurrency(line.amount)}< / td >
< td > ${line.unit || 'stk'}< / td >
< td > ${statusBadge}< / td >
2026-05-06 07:01:43 +02:00
< td > ${lineActions}< / td >
2026-02-17 08:29:05 +01:00
< / tr >
`;
}).join('');
updateSummary();
}
function updateLineAmount(index) {
const line = orderLines[index];
const qty = Number(line.quantity || 0);
const price = Number(line.unit_price || 0);
const discount = Number(line.discount_percentage || 0);
const gross = qty * price;
const net = gross * (1 - (discount / 100));
line.amount = Number(net.toFixed(2));
renderLines();
}
function updateSummary() {
const totalAmount = orderLines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
document.getElementById('sumLines').textContent = orderLines.length;
document.getElementById('sumAmount').textContent = formatCurrency(totalAmount);
}
function addManualLine() {
const newLine = {
line_key: `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: 'manual',
source_id: null,
customer_name: '-',
customer_id: null,
description: 'Ny linje',
quantity: 1,
unit_price: 0,
discount_percentage: 0,
amount: 0,
unit: 'stk',
selected: true,
};
orderLines.push(newLine);
renderLines();
}
function deleteLine(index) {
if (!confirm('Slet denne linje?')) return;
orderLines.splice(index, 1);
renderLines();
}
2026-05-06 07:01:43 +02:00
async function resolveDetailLineProductByCode(index) {
const line = orderLines[index];
if (!line) return;
const defaultCode = String(line.ean || line.sku || line.product_code || '').trim();
const code = prompt('Indtast EAN/strengkode', defaultCode || '');
if (!code || !code.trim()) return;
try {
const customerId = Number(document.getElementById('customerId')?.value || 0) || null;
const params = new URLSearchParams({ code: code.trim(), auto_create: 'true' });
if (customerId) {
params.append('customer_id', String(customerId));
}
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
credentials: 'include',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || 'APIGateway søgning fejlede');
}
if (!data.found || !data.product) {
showToast('Ingen vare fundet på strengkoden', 'warning');
return;
}
const product = data.product;
line.product_id = product.id || line.product_id || null;
line.description = product.name || product.product_name || line.description || '';
2026-09-13 02:30:52 +02:00
if (line.unit_price == null & & product.sales_price != null) {
2026-05-06 07:01:43 +02:00
line.unit_price = Number(product.sales_price || 0);
}
if (product.ean) {
line.ean = product.ean;
}
line.product_code = code.trim();
updateLineAmount(index);
if (data.created) {
if (data.applied_customer_margin_percent != null) {
showToast(`Produkt oprettet med kundeavance ${data.applied_customer_margin_percent}%`, 'success');
} else {
showToast('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
}
} else if (data.source === 'local') {
showToast('Produkt fundet lokalt og tilknyttet linjen', 'success');
} else {
showToast('Produkt fundet via APIGateway og tilknyttet linjen', 'success');
}
} catch (error) {
showToast(error.message || 'Søgning fejlede', 'danger');
}
}
2026-02-17 08:29:05 +01:00
function normalizeOrderLine(line) {
// Handle e-conomic format (product.description, unitNetPrice, etc.)
if (line.product & & line.product.description & & !line.description) {
return {
line_key: line.line_key || `imported-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
source_type: line.source_type || 'manual',
source_id: line.source_id || null,
customer_name: line.customer_name || '-',
customer_id: line.customer_id || null,
description: line.product.description || '',
quantity: Number(line.quantity || 1),
unit_price: Number(line.unitNetPrice || 0),
discount_percentage: Number(line.discountPercentage || 0),
amount: Number(line.totalNetAmount || 0),
unit: line.unit || 'stk',
2026-09-13 02:30:52 +02:00
product_id: null,
economic_product_number: line.product.productNumber || null,
2026-02-17 08:29:05 +01:00
selected: line.selected !== false,
export_status: line.export_status || null,
};
}
// Already in our internal format
return {
...line,
quantity: Number(line.quantity || 1),
unit_price: Number(line.unit_price || 0),
discount_percentage: Number(line.discount_percentage || 0),
amount: Number(line.amount || 0),
};
}
async function loadOrder() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`);
if (!res.ok) throw new Error('Kunne ikke hente ordre');
orderData = await res.json();
orderLines = Array.isArray(orderData.lines_json)
? orderData.lines_json.map(normalizeOrderLine)
: [];
document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || '';
2026-09-13 11:41:50 +02:00
document.getElementById('customerIdentity').textContent = orderData.customer_name
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
: '-';
2026-02-17 08:29:05 +01:00
document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || '';
2026-09-13 11:41:50 +02:00
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
document.getElementById('orderStatus').innerHTML = hasExported
2026-02-17 08:29:05 +01:00
? '< span class = "badge bg-success" > Eksporteret< / span > '
2026-09-13 11:41:50 +02:00
: isUncertain ? '< span class = "badge bg-warning text-dark" > Uafklaret< / span > '
: '< span class = "badge bg-light text-dark border" > Ikke eksporteret< / span > ';
const errorBanner = document.getElementById('exportErrorBanner');
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
errorBanner.classList.toggle('d-none', !orderData.last_export_error);
2026-02-17 08:29:05 +01:00
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
renderLines();
2026-03-23 20:35:15 +01:00
refreshSyncPanelFromOrder();
2026-02-17 08:29:05 +01:00
await loadConfig();
2026-03-23 20:35:15 +01:00
await loadSyncEvents(syncEventsOffset);
2026-02-17 08:29:05 +01:00
} catch (error) {
console.error(error);
2026-03-23 20:35:15 +01:00
showToast(`Fejl: ${error.message}`, 'danger');
}
}
async function updateSyncStatus() {
const payload = {
sync_status: (document.getElementById('syncStatusSelect').value || 'pending').trim().toLowerCase(),
economic_order_number: document.getElementById('economicOrderNumber').value.trim() || null,
economic_invoice_number: document.getElementById('economicInvoiceNumber').value.trim() || null,
};
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/sync-status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke opdatere sync status');
orderData = data;
refreshSyncPanelFromOrder();
await loadSyncEvents(0);
showToast('Sync status opdateret', 'success');
} catch (error) {
showToast(`Fejl: ${error.message}`, 'danger');
}
}
async function markDraftPaid() {
if (!confirm('Markér denne ordre som betalt (kun hvis status er posted)?')) return;
try {
const res = await fetch('/api/v1/billing/drafts/reconcile-sync-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apply: true, mark_paid_ids: [draftId] }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke markere som betalt');
await loadOrder();
if ((orderData.sync_status || '').toLowerCase() !== 'paid') {
showToast('Ingen statusændring. Ordren skal være i status posted før den kan markeres som paid.', 'warning');
return;
}
showToast('Ordre markeret som betalt', 'success');
} catch (error) {
showToast(`Fejl: ${error.message}`, 'danger');
}
}
function buildEventsQuery(offset) {
const params = new URLSearchParams();
params.set('limit', String(syncEventsLimit));
params.set('offset', String(Math.max(0, offset || 0)));
const eventType = document.getElementById('eventTypeFilter').value.trim();
const fromStatus = document.getElementById('fromStatusFilter').value.trim();
const toStatus = document.getElementById('toStatusFilter').value.trim();
const fromDate = document.getElementById('fromDateFilter').value;
const toDate = document.getElementById('toDateFilter').value;
if (eventType) params.set('event_type', eventType);
if (fromStatus) params.set('from_status', fromStatus);
if (toStatus) params.set('to_status', toStatus);
if (fromDate) params.set('from_date', fromDate);
if (toDate) params.set('to_date', toDate);
return params.toString();
}
function renderSyncEvents(items) {
const body = document.getElementById('syncEventsBody');
if (!Array.isArray(items) || items.length === 0) {
body.innerHTML = '< tr > < td colspan = "5" class = "text-muted text-center py-3" > Ingen events fundet< / td > < / tr > ';
return;
}
body.innerHTML = items.map((event) => {
2026-09-13 11:41:50 +02:00
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
let payload = String(event.event_payload || '');
if (event.event_type === 'export_success') {
payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`;
if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`;
} else if (event.event_type === 'export_attempt') {
payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet';
} else if (event.event_type === 'sync_status_manual_update') {
payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`;
} else if (typeof event.event_payload === 'object') {
payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · ');
}
2026-03-23 20:35:15 +01:00
return `
< tr >
< td > ${formatDate(event.created_at)}< / td >
< td > < span class = "badge bg-light text-dark border" > ${escapeHtml(event.event_type || '-')}< / span > < / td >
< td > ${escapeHtml(event.from_status || '-')}< / td >
< td > ${escapeHtml(event.to_status || '-')}< / td >
2026-09-13 11:41:50 +02:00
< td > < div class = "event-payload" > ${escapeHtml(payload)}< / div > < / td >
2026-03-23 20:35:15 +01:00
< / tr >
`;
}).join('');
}
function updateEventsPager() {
const start = syncEventsTotal === 0 ? 0 : syncEventsOffset + 1;
const end = Math.min(syncEventsOffset + syncEventsLimit, syncEventsTotal);
document.getElementById('syncEventsMeta').textContent = `Viser ${start}-${end} af ${syncEventsTotal}`;
document.getElementById('eventsPrevBtn').disabled = syncEventsOffset < = 0;
document.getElementById('eventsNextBtn').disabled = syncEventsOffset + syncEventsLimit >= syncEventsTotal;
}
function changeEventsPage(delta) {
const nextOffset = syncEventsOffset + (delta * syncEventsLimit);
if (nextOffset < 0 | | nextOffset > = syncEventsTotal) {
return;
}
loadSyncEvents(nextOffset);
}
async function loadSyncEvents(offset = 0) {
syncEventsOffset = Math.max(0, offset);
const body = document.getElementById('syncEventsBody');
body.innerHTML = '< tr > < td colspan = "5" class = "text-muted text-center py-3" > Indlæser events...< / td > < / tr > ';
try {
const query = buildEventsQuery(syncEventsOffset);
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/sync-events?${query}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke hente sync events');
syncEventsTotal = Number(data.total || 0);
renderSyncEvents(data.items || []);
updateEventsPager();
} catch (error) {
body.innerHTML = `< tr > < td colspan = "5" class = "text-danger text-center py-3" > ${escapeHtml(error.message)}< / td > < / tr > `;
syncEventsTotal = 0;
updateEventsPager();
2026-02-17 08:29:05 +01:00
}
}
async function loadConfig() {
try {
const res = await fetch('/api/v1/ordre/config');
if (!res.ok) return;
const cfg = await res.json();
if (cfg.economic_read_only || cfg.economic_dry_run) {
document.getElementById('safetyBanner').classList.remove('d-none');
}
if (!document.getElementById('layoutNumber').value & & cfg.default_layout) {
document.getElementById('layoutNumber').value = cfg.default_layout;
}
} catch (err) {
console.error('Config load failed', err);
}
}
async function saveOrder() {
const payload = {
title: document.getElementById('orderTitle').value || 'Ordre',
customer_id: Number(document.getElementById('customerId').value || 0) || null,
lines: orderLines,
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
};
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Kunne ikke gemme ordre');
2026-03-23 20:35:15 +01:00
showToast('Ordre gemt', 'success');
2026-02-17 08:29:05 +01:00
await loadOrder();
} catch (err) {
2026-03-23 20:35:15 +01:00
showToast(`Kunne ikke gemme ordre: ${err.message}`, 'danger');
2026-02-17 08:29:05 +01:00
}
}
2026-09-13 02:30:52 +02:00
async function previewPrices() {
try {
const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`;
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
const preview = await res.json();
if (!res.ok) throw Error(preview.detail || 'Prisberegning fejlede');
const summary = preview.changes.map(x => `${x.description}: ${x.before} → ${x.after}`).join('\n');
if (!preview.changes.length) { showToast('Ingen prisændringer foreslået. Abonnementspriser bevares.', 'info'); return; }
if (!confirm('Genberegn den GEMTE kladde? Ikke-gemte ændringer indgår ikke.\nAbonnementspriser bevares.\n\n'+summary)) return;
const saved = await fetch(url, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:preview.token})});
const data = await saved.json();
if (!saved.ok) throw Error(data.detail || 'Kunne ikke gemme priser');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
2026-09-13 09:56:24 +02:00
function showEconomicProductPreview(detail) {
pendingEconomicProductPreview = detail;
const groups = detail.product_groups || [];
document.getElementById('economicProductPreviewRows').innerHTML = (detail.products || []).map((product, index) => {
const options = groups.map(group => {
const selected = Number(product.suggested_group_number) === Number(group.number) ? ' selected' : '';
return `< option value = "${Number(group.number)}" $ { selected } > ${escapeHtml(group.number)} · ${escapeHtml(group.name)}< / option > `;
}).join('');
return `< div class = "card mb-3" data-economic-product = "${escapeHtml(product.product_number)}" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< strong > Opret produkt ${escapeHtml(product.product_number)}< / strong >
< span class = "badge bg-success" > ${formatCurrency(product.sales_price)}< / span >
< / div >
< div class = "card-body" > < div class = "row g-3" >
< div class = "col-md-4" > < div class = "small text-muted" > Navn< / div > < div > ${escapeHtml(product.name || '-')}< / div > < / div >
< div class = "col-md-4" > < div class = "small text-muted" > EAN / stregkode< / div > < div > ${escapeHtml(product.ean || '-')}< / div > < / div >
< div class = "col-md-4" > < label class = "small text-muted" for = "economicGroup${index}" > Varegruppe< / label > < select id = "economicGroup${index}" class = "form-select form-select-sm" data-product-group > ${options}< / select > < / div >
< div class = "col-12" > < div class = "small text-muted" > Beskrivelse< / div > < div > ${escapeHtml(product.description || '-')}< / div > < / div >
< / div > < / div >
< / div > `;
}).join('');
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicProductPreviewModal')).show();
}
async function confirmEconomicProductCreation() {
if (!pendingEconomicProductPreview) return;
const approvals = {};
document.querySelectorAll('[data-economic-product]').forEach(card => {
approvals[card.dataset.economicProduct] = Number(card.querySelector('[data-product-group]').value);
});
bootstrap.Modal.getInstance(document.getElementById('economicProductPreviewModal'))?.hide();
await exportOrder(approvals);
}
async function exportOrder(createMissingProducts = {}) {
2026-02-17 08:29:05 +01:00
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
2026-03-23 20:35:15 +01:00
showToast('Angiv kunde ID før eksport', 'warning');
2026-02-17 08:29:05 +01:00
return;
}
if (!orderLines.length) {
2026-03-23 20:35:15 +01:00
showToast('Ingen linjer at eksportere', 'warning');
2026-02-17 08:29:05 +01:00
return;
}
const payload = {
customer_id: customerId,
lines: orderLines.map(line => ({
line_key: line.line_key,
source_type: line.source_type,
source_id: line.source_id,
description: line.description,
quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0),
2026-09-13 02:30:52 +02:00
currency: line.currency || 'DKK',
2026-02-17 08:29:05 +01:00
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
2026-09-13 09:56:24 +02:00
ean: line.ean || null,
2026-02-17 08:29:05 +01:00
selected: true,
})),
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId,
2026-03-23 20:35:15 +01:00
force_export: document.getElementById('forceExportToggle').checked,
2026-09-13 09:56:24 +02:00
create_missing_products: createMissingProducts,
2026-02-17 08:29:05 +01:00
};
try {
const res = await fetch('/api/v1/ordre/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
2026-09-13 09:56:24 +02:00
if (data.detail & & data.detail.code === 'economic_products_missing') {
showEconomicProductPreview(data.detail);
return;
}
2026-02-17 08:29:05 +01:00
throw new Error(data.detail || 'Eksport fejlede');
}
2026-03-23 20:35:15 +01:00
showToast(data.message || 'Eksport udført', data.dry_run ? 'warning' : 'success');
2026-02-17 08:29:05 +01:00
await loadOrder();
} catch (err) {
console.error(err);
2026-03-23 20:35:15 +01:00
showToast(`Eksport fejlede: ${err.message}`, 'danger');
2026-02-17 08:29:05 +01:00
}
}
document.addEventListener('DOMContentLoaded', () => {
loadOrder();
});
< / script >
{% endblock %}