417 lines
17 KiB
HTML
417 lines
17 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);
|
||
|
|
}
|
||
|
|
</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>
|
||
|
|
</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 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>
|
||
|
|
</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 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>
|
||
|
|
{% endblock %}
|
||
|
|
|
||
|
|
{% block extra_js %}
|
||
|
|
<script>
|
||
|
|
const draftId = {{ draft_id }};
|
||
|
|
let orderData = null;
|
||
|
|
let orderLines = [];
|
||
|
|
|
||
|
|
function formatCurrency(value) {
|
||
|
|
return new Intl.NumberFormat('da-DK', { style: '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 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 isManual = line.source_type === 'manual';
|
||
|
|
const descriptionField = isManual
|
||
|
|
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
|
||
|
|
onchange="orderLines[${index}].description = this.value;">`
|
||
|
|
: (line.description || '-');
|
||
|
|
|
||
|
|
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>';
|
||
|
|
|
||
|
|
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)}"
|
||
|
|
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)}"
|
||
|
|
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)}"
|
||
|
|
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>
|
||
|
|
${isManual ? `<button class="btn btn-sm btn-outline-danger" onclick="deleteLine(${index})" title="Slet linje"><i class="bi bi-trash"></i></button>` : '-'}
|
||
|
|
</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();
|
||
|
|
}
|
||
|
|
|
||
|
|
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: 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('layoutNumber').value = orderData.layout_number || '';
|
||
|
|
document.getElementById('orderNotes').value = orderData.notes || '';
|
||
|
|
|
||
|
|
const hasExported = orderData.last_exported_at ? true : false;
|
||
|
|
document.getElementById('orderStatus').innerHTML = hasExported
|
||
|
|
? '<span class="badge bg-success">Eksporteret</span>'
|
||
|
|
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
|
||
|
|
|
||
|
|
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
|
||
|
|
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
||
|
|
|
||
|
|
renderLines();
|
||
|
|
await loadConfig();
|
||
|
|
} catch (error) {
|
||
|
|
console.error(error);
|
||
|
|
alert(`Fejl: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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');
|
||
|
|
|
||
|
|
alert('Ordre gemt');
|
||
|
|
await loadOrder();
|
||
|
|
} catch (err) {
|
||
|
|
alert(`Kunne ikke gemme ordre: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function exportOrder() {
|
||
|
|
const customerId = Number(document.getElementById('customerId').value || 0);
|
||
|
|
if (!customerId) {
|
||
|
|
alert('Angiv kunde ID før eksport');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!orderLines.length) {
|
||
|
|
alert('Ingen linjer at eksportere');
|
||
|
|
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),
|
||
|
|
discount_percentage: Number(line.discount_percentage || 0),
|
||
|
|
unit: line.unit || 'stk',
|
||
|
|
product_id: line.product_id || null,
|
||
|
|
selected: true,
|
||
|
|
})),
|
||
|
|
notes: document.getElementById('orderNotes').value || null,
|
||
|
|
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
||
|
|
draft_id: draftId,
|
||
|
|
};
|
||
|
|
|
||
|
|
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) {
|
||
|
|
throw new Error(data.detail || 'Eksport fejlede');
|
||
|
|
}
|
||
|
|
|
||
|
|
alert(data.message || 'Eksport udført');
|
||
|
|
await loadOrder();
|
||
|
|
} catch (err) {
|
||
|
|
console.error(err);
|
||
|
|
alert(`Eksport fejlede: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
||
|
|
loadOrder();
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
{% endblock %}
|