1036 lines
52 KiB
HTML
1036 lines
52 KiB
HTML
{% 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, '&')
|
||
.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>';
|
||
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 %}
|