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

727 lines
30 KiB
HTML
Raw Permalink Normal View History

{% 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 %}