727 lines
30 KiB
HTML
727 lines
30 KiB
HTML
|
|
{% extends "shared/frontend/base.html" %}
|
||
|
|
|
||
|
|
{% block title %}Ordre - BMC Hub{% endblock %}
|
||
|
|
|
||
|
|
{% block extra_css %}
|
||
|
|
<style>
|
||
|
|
.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: var(--text-secondary);
|
||
|
|
}
|
||
|
|
.line-source {
|
||
|
|
font-size: 0.75rem;
|
||
|
|
}
|
||
|
|
.customer-search-wrap {
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
.search-results {
|
||
|
|
position: absolute;
|
||
|
|
top: 100%;
|
||
|
|
left: 0;
|
||
|
|
right: 0;
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid rgba(0,0,0,0.1);
|
||
|
|
border-radius: 8px;
|
||
|
|
max-height: 220px;
|
||
|
|
overflow-y: auto;
|
||
|
|
z-index: 1100;
|
||
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||
|
|
}
|
||
|
|
.search-item {
|
||
|
|
padding: 0.6rem 0.8rem;
|
||
|
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
.search-item:hover {
|
||
|
|
background: var(--accent-light);
|
||
|
|
}
|
||
|
|
.search-item:last-child {
|
||
|
|
border-bottom: none;
|
||
|
|
}
|
||
|
|
.table-secondary {
|
||
|
|
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.08) !important;
|
||
|
|
}
|
||
|
|
.table-secondary td {
|
||
|
|
padding: 0.75rem !important;
|
||
|
|
}
|
||
|
|
.order-header-row {
|
||
|
|
cursor: pointer;
|
||
|
|
transition: background-color 0.2s;
|
||
|
|
}
|
||
|
|
.order-header-row:hover {
|
||
|
|
background-color: rgba(var(--accent-rgb, 15, 76, 117), 0.15) !important;
|
||
|
|
}
|
||
|
|
.order-lines-container {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
.order-lines-container.show {
|
||
|
|
display: table-row-group;
|
||
|
|
}
|
||
|
|
.expand-icon {
|
||
|
|
transition: transform 0.3s;
|
||
|
|
}
|
||
|
|
.expand-icon.expanded {
|
||
|
|
transform: rotate(90deg);
|
||
|
|
}
|
||
|
|
</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>Opret ny ordre</h2>
|
||
|
|
<div class="text-muted">Avanceret samlet ordrevisning (abonnement, hardware, salg)</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-outline-secondary" onclick="expandAllOrders()"><i class="bi bi-arrows-expand me-1"></i>Fold alle ud</button>
|
||
|
|
<button class="btn btn-outline-secondary" onclick="collapseAllOrders()"><i class="bi bi-arrows-collapse me-1"></i>Fold alle sammen</button>
|
||
|
|
<button class="btn btn-outline-secondary" onclick="loadDrafts()"><i class="bi bi-folder2-open me-1"></i>Hent kladder</button>
|
||
|
|
<button class="btn btn-outline-secondary" onclick="saveDraft()"><i class="bi bi-save me-1"></i>Gem kladde</button>
|
||
|
|
<button class="btn btn-outline-primary" onclick="loadOrdreLines()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</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="card mb-3">
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="row g-2 align-items-end">
|
||
|
|
<div class="col-md-4 customer-search-wrap">
|
||
|
|
<label class="form-label">Kunde (kræves ved eksport)</label>
|
||
|
|
<input id="customerSearch" type="text" class="form-control" placeholder="Søg kunde (min. 2 tegn)">
|
||
|
|
<input id="customerId" type="hidden">
|
||
|
|
<div id="customerSearchResults" class="search-results d-none"></div>
|
||
|
|
<div id="selectedCustomerMeta" class="small text-muted mt-1"></div>
|
||
|
|
</div>
|
||
|
|
<div class="col-md-2">
|
||
|
|
<label class="form-label">Sag ID</label>
|
||
|
|
<input id="sagId" type="number" class="form-control" placeholder="fx 456">
|
||
|
|
</div>
|
||
|
|
<div class="col-md-3">
|
||
|
|
<label class="form-label">Søg</label>
|
||
|
|
<input id="searchText" type="text" class="form-control" placeholder="Beskrivelse, kunde eller sag">
|
||
|
|
</div>
|
||
|
|
<div class="col-md-3">
|
||
|
|
<label class="form-label">Layout nr.</label>
|
||
|
|
<input id="layoutNumber" type="number" class="form-control" placeholder="e-conomic layout">
|
||
|
|
</div>
|
||
|
|
<div class="col-md-4">
|
||
|
|
<label class="form-label">Kladde</label>
|
||
|
|
<select id="draftSelect" class="form-select">
|
||
|
|
<option value="">Vælg kladde...</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div class="col-md-2 d-grid">
|
||
|
|
<button class="btn btn-outline-primary" onclick="loadSelectedDraft()"><i class="bi bi-box-arrow-in-down me-1"></i>Indlæs</button>
|
||
|
|
</div>
|
||
|
|
<div class="col-md-2 d-grid">
|
||
|
|
<button class="btn btn-outline-danger" onclick="deleteSelectedDraft()"><i class="bi bi-trash me-1"></i>Slet</button>
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label">Noter (til e-conomic)</label>
|
||
|
|
<textarea id="exportNotes" class="form-control" rows="2" placeholder="Valgfri note 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">Linjer total</div><div id="sumLines" class="summary-value">0</div></div></div>
|
||
|
|
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Valgte linjer</div><div id="sumSelectedLines" class="summary-value">0</div></div></div>
|
||
|
|
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Beløb total</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">Valgt beløb</div><div id="sumSelectedAmount" class="summary-value">0 kr.</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 style="width: 30px;"></th>
|
||
|
|
<th style="width: 50px;">Valg</th>
|
||
|
|
<th>Kilde</th>
|
||
|
|
<th>Beskrivelse</th>
|
||
|
|
<th>Antal</th>
|
||
|
|
<th>Pris</th>
|
||
|
|
<th>Rabat %</th>
|
||
|
|
<th>Beløb</th>
|
||
|
|
<th>Eksport</th>
|
||
|
|
<th>Handling</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="ordreLinesBody">
|
||
|
|
<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="d-flex justify-content-end mt-3">
|
||
|
|
<button class="btn btn-success" onclick="exportOrdre()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{% endblock %}
|
||
|
|
|
||
|
|
{% block extra_js %}
|
||
|
|
<script>
|
||
|
|
let ordreLines = [];
|
||
|
|
let customerSearchTimeout = null;
|
||
|
|
let customerSearchResultsCache = [];
|
||
|
|
|
||
|
|
function formatCurrency(value) {
|
||
|
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
||
|
|
}
|
||
|
|
|
||
|
|
function sourceBadge(type) {
|
||
|
|
if (type === 'subscription') return '<span class="badge bg-primary line-source">Abonnement</span>';
|
||
|
|
if (type === 'hardware') return '<span class="badge bg-secondary line-source">Hardware</span>';
|
||
|
|
if (type === 'manual') return '<span class="badge bg-info line-source">Manuel</span>';
|
||
|
|
return '<span class="badge bg-success line-source">Salg</span>';
|
||
|
|
}
|
||
|
|
|
||
|
|
function addManualLine() {
|
||
|
|
const customerId = Number(document.getElementById('customerId').value || 0) || null;
|
||
|
|
const customerName = document.getElementById('customerSearch').value || 'Manuel ordre';
|
||
|
|
|
||
|
|
const newLine = {
|
||
|
|
line_key: `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||
|
|
source_type: 'manual',
|
||
|
|
source_id: null,
|
||
|
|
customer_name: customerName,
|
||
|
|
customer_id: customerId,
|
||
|
|
sag_title: null,
|
||
|
|
sag_id: null,
|
||
|
|
description: 'Ny linje',
|
||
|
|
quantity: 1,
|
||
|
|
unit_price: 0,
|
||
|
|
discount_percentage: 0,
|
||
|
|
amount: 0,
|
||
|
|
unit: 'stk',
|
||
|
|
product_id: null,
|
||
|
|
selected: true,
|
||
|
|
export_status: null,
|
||
|
|
};
|
||
|
|
ordreLines.push(newLine);
|
||
|
|
renderLines();
|
||
|
|
}
|
||
|
|
|
||
|
|
function deleteLine(index) {
|
||
|
|
if (!confirm('Slet denne linje?')) return;
|
||
|
|
ordreLines.splice(index, 1);
|
||
|
|
renderLines();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleGroupSelection(indices, selected) {
|
||
|
|
indices.forEach(index => {
|
||
|
|
ordreLines[index].selected = selected;
|
||
|
|
});
|
||
|
|
renderLines();
|
||
|
|
}
|
||
|
|
|
||
|
|
function recalcSummary() {
|
||
|
|
const totalAmount = ordreLines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
|
||
|
|
const selected = ordreLines.filter(line => line.selected);
|
||
|
|
const selectedAmount = selected.reduce((sum, line) => sum + Number(line.amount || 0), 0);
|
||
|
|
|
||
|
|
document.getElementById('sumLines').textContent = ordreLines.length;
|
||
|
|
document.getElementById('sumSelectedLines').textContent = selected.length;
|
||
|
|
document.getElementById('sumAmount').textContent = formatCurrency(totalAmount);
|
||
|
|
document.getElementById('sumSelectedAmount').textContent = formatCurrency(selectedAmount);
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateLineAmount(index) {
|
||
|
|
const line = ordreLines[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));
|
||
|
|
const amountEl = document.getElementById(`lineAmount-${index}`);
|
||
|
|
if (amountEl) amountEl.textContent = formatCurrency(line.amount);
|
||
|
|
|
||
|
|
// Re-render to update group totals
|
||
|
|
renderLines();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderLines() {
|
||
|
|
const body = document.getElementById('ordreLinesBody');
|
||
|
|
if (!ordreLines.length) {
|
||
|
|
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Ingen linjer fundet</td></tr>';
|
||
|
|
recalcSummary();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Group lines by customer_id (or use 'manual' for manual entries without customer)
|
||
|
|
const grouped = {};
|
||
|
|
ordreLines.forEach((line, index) => {
|
||
|
|
const groupKey = line.customer_id || line.customer_name || 'manual';
|
||
|
|
if (!grouped[groupKey]) {
|
||
|
|
grouped[groupKey] = {
|
||
|
|
customer_name: line.customer_name || 'Manuel ordre',
|
||
|
|
customer_id: line.customer_id || null,
|
||
|
|
lines: []
|
||
|
|
};
|
||
|
|
}
|
||
|
|
grouped[groupKey].lines.push({ ...line, originalIndex: index });
|
||
|
|
});
|
||
|
|
|
||
|
|
// Render grouped lines with collapsible rows
|
||
|
|
let html = '';
|
||
|
|
Object.keys(grouped).forEach((groupKey, groupIndex) => {
|
||
|
|
const group = grouped[groupKey];
|
||
|
|
const groupTotal = group.lines.reduce((sum, line) => sum + Number(line.amount || 0), 0);
|
||
|
|
const groupSelected = group.lines.filter(line => line.selected).length;
|
||
|
|
const allSelected = groupSelected === group.lines.length;
|
||
|
|
const lineIndices = group.lines.map(line => line.originalIndex);
|
||
|
|
|
||
|
|
// Group header row (clickable to expand/collapse)
|
||
|
|
html += `
|
||
|
|
<tr class="table-secondary order-header-row" onclick="toggleOrderLines('order-${groupIndex}')">
|
||
|
|
<td>
|
||
|
|
<i class="bi bi-chevron-right expand-icon" id="icon-order-${groupIndex}"></i>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<input type="checkbox" ${allSelected ? 'checked' : ''}
|
||
|
|
onclick="event.stopPropagation();"
|
||
|
|
onchange="toggleGroupSelection([${lineIndices.join(',')}], this.checked);"
|
||
|
|
title="Vælg/fravælg alle">
|
||
|
|
</td>
|
||
|
|
<td colspan="8">
|
||
|
|
<div class="d-flex justify-content-between align-items-center">
|
||
|
|
<div>
|
||
|
|
<i class="bi bi-folder2 me-2"></i><strong>${group.customer_name}</strong>
|
||
|
|
${group.customer_id ? ` <span class="badge bg-light text-dark border">Kunde ${group.customer_id}</span>` : ''}
|
||
|
|
</div>
|
||
|
|
<div class="text-end">
|
||
|
|
<span class="badge bg-primary me-2">${group.lines.length} ${group.lines.length === 1 ? 'linje' : 'linjer'}</span>
|
||
|
|
<span class="badge bg-success me-2">${groupSelected} valgt</span>
|
||
|
|
<span class="fw-bold">${formatCurrency(groupTotal)}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
`;
|
||
|
|
|
||
|
|
// Render lines in this group (hidden by default)
|
||
|
|
group.lines.forEach((line) => {
|
||
|
|
const index = line.originalIndex;
|
||
|
|
const isManual = line.source_type === 'manual';
|
||
|
|
const descriptionField = isManual
|
||
|
|
? `<input type="text" class="form-control form-control-sm" value="${line.description || ''}"
|
||
|
|
onchange="ordreLines[${index}].description = this.value;">`
|
||
|
|
: (line.description || '-');
|
||
|
|
|
||
|
|
html += `
|
||
|
|
<tr class="order-lines-container" data-order="order-${groupIndex}">
|
||
|
|
<td></td>
|
||
|
|
<td>
|
||
|
|
<input type="checkbox" ${line.selected ? 'checked' : ''} onchange="ordreLines[${index}].selected = this.checked; recalcSummary();">
|
||
|
|
</td>
|
||
|
|
<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="ordreLines[${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="ordreLines[${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="ordreLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
|
||
|
|
</td>
|
||
|
|
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||
|
|
<td>${renderExportStatusBadge(line)}</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>
|
||
|
|
`;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
body.innerHTML = html;
|
||
|
|
recalcSummary();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleOrderLines(orderId) {
|
||
|
|
const lines = document.querySelectorAll(`tr[data-order="${orderId}"]`);
|
||
|
|
const icon = document.getElementById(`icon-${orderId}`);
|
||
|
|
|
||
|
|
lines.forEach(line => {
|
||
|
|
line.classList.toggle('show');
|
||
|
|
});
|
||
|
|
|
||
|
|
if (icon) {
|
||
|
|
icon.classList.toggle('expanded');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function expandAllOrders() {
|
||
|
|
document.querySelectorAll('.order-lines-container').forEach(line => {
|
||
|
|
line.classList.add('show');
|
||
|
|
});
|
||
|
|
document.querySelectorAll('.expand-icon').forEach(icon => {
|
||
|
|
icon.classList.add('expanded');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function collapseAllOrders() {
|
||
|
|
document.querySelectorAll('.order-lines-container').forEach(line => {
|
||
|
|
line.classList.remove('show');
|
||
|
|
});
|
||
|
|
document.querySelectorAll('.expand-icon').forEach(icon => {
|
||
|
|
icon.classList.remove('expanded');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderExportStatusBadge(line) {
|
||
|
|
const status = line.export_status || '';
|
||
|
|
if (status === 'exported') {
|
||
|
|
return '<span class="badge bg-success">Eksporteret</span>';
|
||
|
|
}
|
||
|
|
if (status === 'dry-run') {
|
||
|
|
return '<span class="badge bg-warning text-dark">Dry-run</span>';
|
||
|
|
}
|
||
|
|
return '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectCustomer(customer) {
|
||
|
|
document.getElementById('customerId').value = customer.id;
|
||
|
|
document.getElementById('customerSearch').value = customer.name || '';
|
||
|
|
document.getElementById('selectedCustomerMeta').textContent = `ID ${customer.id}${customer.cvr_nummer ? ' · CVR ' + customer.cvr_nummer : ''}`;
|
||
|
|
document.getElementById('customerSearchResults').classList.add('d-none');
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearCustomerSelection() {
|
||
|
|
document.getElementById('customerId').value = '';
|
||
|
|
document.getElementById('selectedCustomerMeta').textContent = '';
|
||
|
|
}
|
||
|
|
|
||
|
|
async function searchCustomers(query) {
|
||
|
|
const resultsEl = document.getElementById('customerSearchResults');
|
||
|
|
if (!query || query.length < 2) {
|
||
|
|
resultsEl.classList.add('d-none');
|
||
|
|
resultsEl.innerHTML = '';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error('Kundesøgning fejlede');
|
||
|
|
}
|
||
|
|
const customers = await response.json();
|
||
|
|
if (!Array.isArray(customers) || customers.length === 0) {
|
||
|
|
resultsEl.innerHTML = '<div class="search-item text-muted">Ingen kunder fundet</div>';
|
||
|
|
resultsEl.classList.remove('d-none');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
customerSearchResultsCache = customers;
|
||
|
|
|
||
|
|
resultsEl.innerHTML = customers.map((customer, index) => `
|
||
|
|
<div class="search-item" onclick="selectCustomerByIndex(${index})">
|
||
|
|
<div class="fw-semibold">${customer.name || '-'}</div>
|
||
|
|
<div class="small text-muted">ID ${customer.id}${customer.cvr_nummer ? ' · CVR ' + customer.cvr_nummer : ''}</div>
|
||
|
|
</div>
|
||
|
|
`).join('');
|
||
|
|
resultsEl.classList.remove('d-none');
|
||
|
|
} catch (err) {
|
||
|
|
resultsEl.innerHTML = '<div class="search-item text-danger">Fejl ved kundesøgning</div>';
|
||
|
|
resultsEl.classList.remove('d-none');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectCustomerByIndex(index) {
|
||
|
|
const customer = customerSearchResultsCache[index];
|
||
|
|
if (!customer) return;
|
||
|
|
selectCustomer(customer);
|
||
|
|
}
|
||
|
|
|
||
|
|
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 (cfg.default_layout) {
|
||
|
|
document.getElementById('layoutNumber').value = cfg.default_layout;
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Config load failed', err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadOrdreLines() {
|
||
|
|
const customerId = document.getElementById('customerId').value;
|
||
|
|
const sagId = document.getElementById('sagId').value;
|
||
|
|
const q = document.getElementById('searchText').value.trim();
|
||
|
|
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
if (customerId) params.append('customer_id', customerId);
|
||
|
|
if (sagId) params.append('sag_id', sagId);
|
||
|
|
if (q) params.append('q', q);
|
||
|
|
|
||
|
|
const body = document.getElementById('ordreLinesBody');
|
||
|
|
body.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-4">Indlæser...</td></tr>';
|
||
|
|
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/v1/ordre/aggregate?${params.toString()}`);
|
||
|
|
if (!res.ok) throw new Error('Failed to load aggregate');
|
||
|
|
const data = await res.json();
|
||
|
|
ordreLines = data.lines || [];
|
||
|
|
renderLines();
|
||
|
|
} catch (err) {
|
||
|
|
console.error(err);
|
||
|
|
body.innerHTML = '<tr><td colspan="10" class="text-danger text-center py-4">Kunne ikke hente ordrelinjer</td></tr>';
|
||
|
|
ordreLines = [];
|
||
|
|
recalcSummary();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadDrafts() {
|
||
|
|
const select = document.getElementById('draftSelect');
|
||
|
|
select.innerHTML = '<option value="">Indlæser kladder...</option>';
|
||
|
|
try {
|
||
|
|
const res = await fetch('/api/v1/ordre/drafts');
|
||
|
|
if (!res.ok) throw new Error('Kunne ikke hente kladder');
|
||
|
|
const drafts = await res.json();
|
||
|
|
select.innerHTML = '<option value="">Vælg kladde...</option>' + (drafts || []).map(d =>
|
||
|
|
`<option value="${d.id}">${d.title} (#${d.id})</option>`
|
||
|
|
).join('');
|
||
|
|
} catch (err) {
|
||
|
|
select.innerHTML = '<option value="">Fejl ved indlæsning</option>';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveDraft() {
|
||
|
|
const title = prompt('Navn på kladde:', 'Ordrekladde');
|
||
|
|
if (!title) return;
|
||
|
|
|
||
|
|
const selectedDraftId = Number(document.getElementById('draftSelect').value || 0);
|
||
|
|
|
||
|
|
const payload = {
|
||
|
|
title,
|
||
|
|
customer_id: Number(document.getElementById('customerId').value || 0) || null,
|
||
|
|
lines: ordreLines,
|
||
|
|
notes: document.getElementById('exportNotes').value || null,
|
||
|
|
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
const isUpdate = selectedDraftId > 0;
|
||
|
|
const endpoint = isUpdate ? `/api/v1/ordre/drafts/${selectedDraftId}` : '/api/v1/ordre/drafts';
|
||
|
|
const method = isUpdate ? 'PATCH' : 'POST';
|
||
|
|
|
||
|
|
const res = await fetch(endpoint, {
|
||
|
|
method,
|
||
|
|
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 kladde');
|
||
|
|
|
||
|
|
if (data.id && !isUpdate) {
|
||
|
|
// Redirect to detail page after creating new order
|
||
|
|
window.location.href = `/ordre/${data.id}`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await loadDrafts();
|
||
|
|
if (data.id) {
|
||
|
|
document.getElementById('draftSelect').value = String(data.id);
|
||
|
|
}
|
||
|
|
alert('Kladde gemt');
|
||
|
|
} catch (err) {
|
||
|
|
alert(`Kunne ikke gemme kladde: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadSelectedDraft() {
|
||
|
|
const draftId = Number(document.getElementById('draftSelect').value || 0);
|
||
|
|
if (!draftId) {
|
||
|
|
alert('Vælg en kladde først');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`);
|
||
|
|
const draft = await res.json();
|
||
|
|
if (!res.ok) throw new Error(draft.detail || 'Kunne ikke hente kladde');
|
||
|
|
|
||
|
|
ordreLines = Array.isArray(draft.lines_json) ? draft.lines_json : [];
|
||
|
|
document.getElementById('exportNotes').value = draft.notes || '';
|
||
|
|
document.getElementById('layoutNumber').value = draft.layout_number || '';
|
||
|
|
|
||
|
|
const exportStatus = (draft.export_status_json && typeof draft.export_status_json === 'object')
|
||
|
|
? draft.export_status_json
|
||
|
|
: {};
|
||
|
|
ordreLines = ordreLines.map((line) => {
|
||
|
|
const key = line.line_key;
|
||
|
|
const statusMeta = key ? exportStatus[key] : null;
|
||
|
|
if (statusMeta && statusMeta.status) {
|
||
|
|
return {
|
||
|
|
...line,
|
||
|
|
export_status: statusMeta.status,
|
||
|
|
exported_at: statusMeta.timestamp || null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return line;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (draft.customer_id) {
|
||
|
|
document.getElementById('customerId').value = draft.customer_id;
|
||
|
|
document.getElementById('customerSearch').value = `Kunde #${draft.customer_id}`;
|
||
|
|
document.getElementById('selectedCustomerMeta').textContent = `ID ${draft.customer_id}`;
|
||
|
|
} else {
|
||
|
|
document.getElementById('customerSearch').value = '';
|
||
|
|
clearCustomerSelection();
|
||
|
|
}
|
||
|
|
|
||
|
|
renderLines();
|
||
|
|
alert('Kladde indlæst');
|
||
|
|
} catch (err) {
|
||
|
|
alert(`Kunne ikke indlæse kladde: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function deleteSelectedDraft() {
|
||
|
|
const draftId = Number(document.getElementById('draftSelect').value || 0);
|
||
|
|
if (!draftId) {
|
||
|
|
alert('Vælg en kladde først');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!confirm('Slet denne kladde?')) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/v1/ordre/drafts/${draftId}`, { method: 'DELETE' });
|
||
|
|
const data = await res.json();
|
||
|
|
if (!res.ok) throw new Error(data.detail || 'Kunne ikke slette kladde');
|
||
|
|
await loadDrafts();
|
||
|
|
alert('Kladde slettet');
|
||
|
|
} catch (err) {
|
||
|
|
alert(`Kunne ikke slette kladde: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function exportOrdre() {
|
||
|
|
const customerId = Number(document.getElementById('customerId').value || 0);
|
||
|
|
if (!customerId) {
|
||
|
|
alert('Vælg kunde før eksport');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedLines = ordreLines.filter(line => line.selected);
|
||
|
|
if (!selectedLines.length) {
|
||
|
|
alert('Vælg mindst én linje');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const payload = {
|
||
|
|
customer_id: customerId,
|
||
|
|
lines: selectedLines.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('exportNotes').value || null,
|
||
|
|
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
||
|
|
draft_id: Number(document.getElementById('draftSelect').value || 0) || null,
|
||
|
|
};
|
||
|
|
|
||
|
|
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');
|
||
|
|
}
|
||
|
|
|
||
|
|
const exportedLineKeys = data.exported_line_keys || [];
|
||
|
|
const status = data.dry_run ? 'dry-run' : 'exported';
|
||
|
|
ordreLines.forEach((line) => {
|
||
|
|
if (exportedLineKeys.includes(line.line_key)) {
|
||
|
|
line.export_status = status;
|
||
|
|
line.exported_at = new Date().toISOString();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
renderLines();
|
||
|
|
|
||
|
|
alert(data.message || 'Eksport udført');
|
||
|
|
} catch (err) {
|
||
|
|
console.error(err);
|
||
|
|
alert(`Eksport fejlede: ${err.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
||
|
|
const customerSearchInput = document.getElementById('customerSearch');
|
||
|
|
if (customerSearchInput) {
|
||
|
|
customerSearchInput.addEventListener('input', () => {
|
||
|
|
const query = customerSearchInput.value.trim();
|
||
|
|
clearTimeout(customerSearchTimeout);
|
||
|
|
customerSearchTimeout = setTimeout(() => {
|
||
|
|
if (!query) {
|
||
|
|
clearCustomerSelection();
|
||
|
|
}
|
||
|
|
searchCustomers(query);
|
||
|
|
}, 200);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener('click', (event) => {
|
||
|
|
const resultsEl = document.getElementById('customerSearchResults');
|
||
|
|
const searchInput = document.getElementById('customerSearch');
|
||
|
|
if (!resultsEl || !searchInput) return;
|
||
|
|
if (resultsEl.contains(event.target) || searchInput.contains(event.target)) return;
|
||
|
|
resultsEl.classList.add('d-none');
|
||
|
|
});
|
||
|
|
|
||
|
|
await loadConfig();
|
||
|
|
await loadDrafts();
|
||
|
|
await loadOrdreLines();
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
{% endblock %}
|