bmc_hub/app/modules/orders/templates/detail.html

1036 lines
52 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.

{% 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);
}
.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;
}
</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>
<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>
</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>
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
<div id="forceExportWrap" class="d-flex justify-content-end mb-3">
<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>
<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">
<div id="customerIdentity" class="small text-muted mt-1">-</div>
</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>
<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>
<div class="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<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>
</div>
<div class="d-flex gap-2">
<button id="economicUpdateBtn" class="btn btn-outline-primary btn-sm d-none" onclick="previewEconomicUpdate()">
<i class="bi bi-pencil-square me-1"></i>Opdatér e-conomic-kladde
</button>
<button id="economicStatusBtn" class="btn btn-outline-secondary btn-sm d-none" onclick="refreshEconomicStatus()">
<i class="bi bi-cloud-arrow-down me-1"></i>Hent status nu
</button>
<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>
<option value="uncertain">uncertain</option>
<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>
<div class="col-md-3" id="invoiceNumberEditWrap">
<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>
<div class="col-md-3" id="invoiceNumberViewWrap">
<div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="sync-label">Betalingsstatus</div>
<div id="economicPaymentStatusView" class="sync-value">-</div>
</div>
</div>
<div class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<label class="form-label mb-1">Hændelse</label>
<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>
<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>
<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>
<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>
<div class="modal fade" id="economicUpdatePreviewModal" 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">Opdatér e-conomic-ordrekladde</h5><div class="text-muted small">Kontrollér før og efter. Kun den åbne ordrekladde ændres.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="alert alert-info">Der skrives først til e-conomic, når du vælger <strong>Godkend og opdatér</strong>.</div><div id="economicUpdateComparison"></div></div>
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" onclick="confirmEconomicUpdate()">Godkend og opdatér</button></div>
</div></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const draftId = {{ draft_id }};
let orderData = null;
let orderLines = [];
const syncEventsLimit = 10;
let syncEventsOffset = 0;
let syncEventsTotal = 0;
let detailToast = null;
let pendingEconomicProductPreview = null;
let pendingEconomicUpdateId = null;
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();
}
function formatCurrency(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
}
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>';
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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>';
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
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 || '-';
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;
}
}
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) => {
const isExportedLine = line.export_status === 'exported';
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
const lockLine = isExportedLine && !mayEditExportedDraft;
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">`;
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>';
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>
`;
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)}"
${lockLine ? 'disabled' : ''}
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)}"
${lockLine ? 'disabled' : ''}
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)}"
${lockLine ? 'disabled' : ''}
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>
<td>${lineActions}</td>
</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();
}
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 || '';
if (line.unit_price == null && product.sales_price != null) {
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');
}
}
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',
product_id: null,
economic_product_number: line.product.productNumber || null,
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 || '';
document.getElementById('customerIdentity').textContent = orderData.customer_name
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
: '-';
document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>'
: 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);
const openEconomicDraft = orderData.sync_status === 'exported' && orderData.economic_order_number && !orderData.economic_invoice_number;
document.getElementById('economicUpdateBtn').classList.toggle('d-none', !openEconomicDraft);
document.getElementById('economicStatusBtn').classList.toggle('d-none', !orderData.economic_order_number);
document.getElementById('economicPaymentStatusView').textContent = orderData.economic_payment_status || (orderData.economic_document_state === 'draft' ? 'Ikke faktureret' : '-');
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
renderLines();
refreshSyncPanelFromOrder();
await loadConfig();
await loadSyncEvents(syncEventsOffset);
} catch (error) {
console.error(error);
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) => {
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(' · ');
}
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>
<td><div class="event-payload">${escapeHtml(payload)}</div></td>
</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();
}
}
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');
showToast('Ordre gemt', 'success');
await loadOrder();
} catch (err) {
showToast(`Kunne ikke gemme ordre: ${err.message}`, 'danger');
}
}
async function previewEconomicUpdate() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-preview`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({lines: orderLines, notes: document.getElementById('orderNotes').value || null}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Forhåndsvisning fejlede');
pendingEconomicUpdateId = data.request_id;
const before = data.before || {}, after = data.after || {};
const rows = (after.lines || []).map((line, i) => {
const old = (before.lines || [])[i] || {};
return `<tr><td>${escapeHtml(line.product_number || '-')}</td><td>${escapeHtml(old.description)}${escapeHtml(line.description)}</td><td>${escapeHtml(old.quantity)}${escapeHtml(line.quantity)}</td><td>${escapeHtml(old.unit_price)}${escapeHtml(line.unit_price)}</td><td>${escapeHtml(old.discount_percentage)}${escapeHtml(line.discount_percentage)}</td></tr>`;
}).join('');
document.getElementById('economicUpdateComparison').innerHTML = `<div class="mb-3"><strong>Noter:</strong> ${escapeHtml(before.notes || '-')}${escapeHtml(after.notes || '-')}</div><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Vare</th><th>Beskrivelse</th><th>Antal</th><th>Pris</th><th>Rabat %</th></tr></thead><tbody>${rows}</tbody></table></div>`;
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicUpdatePreviewModal')).show();
} catch (err) { showToast(err.message, 'danger'); }
}
async function confirmEconomicUpdate() {
if (!pendingEconomicUpdateId) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-confirm`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:pendingEconomicUpdateId})});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opdateringen fejlede');
bootstrap.Modal.getInstance(document.getElementById('economicUpdatePreviewModal'))?.hide();
pendingEconomicUpdateId = null;
showToast('e-conomic-ordrekladden er opdateret', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
async function refreshEconomicStatus() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-status-refresh`, {method:'POST'});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Statuskontrol fejlede');
showToast(data.invoice_number ? `Faktura ${data.invoice_number}: ${data.payment_status || data.status}` : 'Ordren er stadig en åben kladde', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
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'); }
}
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 = {}) {
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
showToast('Angiv kunde ID før eksport', 'warning');
return;
}
if (!orderLines.length) {
showToast('Ingen linjer at eksportere', 'warning');
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),
currency: line.currency || 'DKK',
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
ean: line.ean || null,
selected: true,
})),
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId,
force_export: document.getElementById('forceExportToggle').checked,
create_missing_products: createMissingProducts,
};
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) {
if (data.detail && data.detail.code === 'economic_products_missing') {
showEconomicProductPreview(data.detail);
return;
}
throw new Error(data.detail || 'Eksport fejlede');
}
showToast(data.message || 'Eksport udført', data.dry_run ? 'warning' : 'success');
await loadOrder();
} catch (err) {
console.error(err);
showToast(`Eksport fejlede: ${err.message}`, 'danger');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadOrder();
});
</script>
{% endblock %}