feat: Update frontend navigation and links for support and CRM sections fix: Modify subscription listing and stats endpoints to support 'all' status feat: Implement subscription status filter in the subscriptions list view feat: Redirect ticket routes to the new sag path feat: Integrate devportal routes into the main application feat: Create a wizard for location creation with nested floors and rooms feat: Add product suppliers table to track multiple suppliers per product feat: Implement product audit log to track changes in products feat: Extend location types to include kantine and moedelokale feat: Add last_2fa_at column to users table for 2FA grace period tracking
715 lines
30 KiB
HTML
715 lines
30 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Produkt - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Serif:wght@400;500;600&display=swap">
|
|
<style>
|
|
:root {
|
|
--product-bg-1: #f5f7fb;
|
|
--product-bg-2: #e9f1f7;
|
|
--product-ink: #102a43;
|
|
--product-ink-muted: #486581;
|
|
--product-accent: #0f4c75;
|
|
--product-card: #ffffff;
|
|
--product-border: rgba(15, 76, 117, 0.12);
|
|
}
|
|
|
|
[data-bs-theme="dark"] {
|
|
--product-bg-1: #1b1f24;
|
|
--product-bg-2: #20252b;
|
|
--product-ink: #f1f5f9;
|
|
--product-ink-muted: #b6c2d1;
|
|
--product-card: #252b33;
|
|
--product-border: rgba(61, 139, 253, 0.18);
|
|
}
|
|
|
|
.product-page {
|
|
font-family: "Space Grotesk", sans-serif;
|
|
color: var(--product-ink);
|
|
background: radial-gradient(circle at 10% 10%, var(--product-bg-2), transparent 45%),
|
|
radial-gradient(circle at 90% 20%, rgba(15, 76, 117, 0.15), transparent 50%),
|
|
linear-gradient(160deg, var(--product-bg-1), var(--product-bg-2));
|
|
border-radius: 18px;
|
|
padding: 28px;
|
|
box-shadow: 0 20px 40px rgba(15, 76, 117, 0.08);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.product-page h1 {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.product-muted {
|
|
color: var(--product-ink-muted);
|
|
}
|
|
|
|
.product-card {
|
|
background: var(--product-card);
|
|
border-radius: 16px;
|
|
border: 1px solid var(--product-border);
|
|
padding: 18px;
|
|
box-shadow: 0 16px 30px rgba(15, 76, 117, 0.08);
|
|
}
|
|
|
|
.product-table th {
|
|
background: rgba(15, 76, 117, 0.06);
|
|
}
|
|
|
|
.badge-soft {
|
|
background: rgba(15, 76, 117, 0.12);
|
|
color: var(--product-ink);
|
|
border-radius: 999px;
|
|
padding: 4px 10px;
|
|
font-size: 0.75rem;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4">
|
|
<div class="product-page">
|
|
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-4">
|
|
<div>
|
|
<a href="/products" class="text-decoration-none product-muted">← Tilbage til produkter</a>
|
|
<h1 id="productName">Produkt</h1>
|
|
<div class="product-muted" id="productMeta"></div>
|
|
</div>
|
|
<div class="text-end">
|
|
<div class="badge-soft" id="productStatus">-</div>
|
|
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
|
|
<div class="product-muted" id="productSku">-</div>
|
|
<div class="product-muted" id="productSupplierPrice">-</div>
|
|
<div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-lg-4">
|
|
<div class="product-card h-100">
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<h5 class="mb-0">Produktnavn</h5>
|
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteProduct()">Slet</button>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Navn</label>
|
|
<input type="text" class="form-control" id="productNameInput">
|
|
</div>
|
|
<button class="btn btn-outline-primary w-100 mb-3" onclick="updateProductName()">Opdater navn</button>
|
|
<div class="small mb-3" id="productNameMessage"></div>
|
|
<h5 class="mb-3">Opdater pris</h5>
|
|
<div class="mb-3">
|
|
<label class="form-label">Ny salgspris</label>
|
|
<input type="number" class="form-control" id="priceNewValue" step="0.01" min="0">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Note</label>
|
|
<input type="text" class="form-control" id="priceNote" placeholder="Aarsag til prisændring">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Opdateret af</label>
|
|
<input type="text" class="form-control" id="priceChangedBy" placeholder="Navn">
|
|
</div>
|
|
<button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button>
|
|
<div class="small mt-3" id="priceUpdateMessage"></div>
|
|
</div>
|
|
<div class="product-card mt-3">
|
|
<h5 class="mb-3">Opdater leverandoer</h5>
|
|
<div class="mb-3">
|
|
<label class="form-label">Leverandoer</label>
|
|
<input type="text" class="form-control" id="supplierName" placeholder="Leverandoer navn">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Leverandoer pris</label>
|
|
<input type="number" class="form-control" id="supplierPrice" step="0.01" min="0">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Note</label>
|
|
<input type="text" class="form-control" id="supplierNote" placeholder="Aarsag til ændring">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Opdateret af</label>
|
|
<input type="text" class="form-control" id="supplierChangedBy" placeholder="Navn">
|
|
</div>
|
|
<button class="btn btn-outline-primary w-100" onclick="submitSupplierUpdate()">Gem leverandoer</button>
|
|
<div class="small mt-3" id="supplierUpdateMessage"></div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-8">
|
|
<div class="product-card mb-3">
|
|
<h5 class="mb-3">Produktinfo</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm product-table mb-0">
|
|
<tbody id="productInfoBody">
|
|
<tr><td class="text-center product-muted py-3">Indlaeser...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="product-card mb-3">
|
|
<h5 class="mb-3">Pris historik</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm product-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Dato</th>
|
|
<th>Type</th>
|
|
<th>Gammel</th>
|
|
<th>Ny</th>
|
|
<th>Note</th>
|
|
<th>Af</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="priceHistoryBody">
|
|
<tr><td colspan="6" class="text-center product-muted py-3">Indlaeser...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="product-card">
|
|
<h5 class="mb-3">Tidligere salg</h5>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm product-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Dato</th>
|
|
<th>Kilde</th>
|
|
<th>Beskrivelse</th>
|
|
<th>Antal</th>
|
|
<th>Pris</th>
|
|
<th>Total</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="salesHistoryBody">
|
|
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="product-card mt-3">
|
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
|
<h5 class="mb-0">Grosister og priser</h5>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<select class="form-select form-select-sm" style="max-width: 160px;" onchange="changeSupplierSort(this.value)">
|
|
<option value="price">Pris</option>
|
|
<option value="stock">Lager</option>
|
|
<option value="name">Navn</option>
|
|
<option value="updated">Opdateret</option>
|
|
</select>
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="refreshSuppliersFromGateway()">Opdater fra Gateway</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive mb-3">
|
|
<table class="table table-sm product-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Grosist</th>
|
|
<th>SKU</th>
|
|
<th>Pris</th>
|
|
<th>Lager</th>
|
|
<th>Link</th>
|
|
<th>Opdateret</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="supplierListBody">
|
|
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="row g-2 align-items-end">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Grosist</label>
|
|
<input type="text" class="form-control" id="supplierListName" placeholder="Navn">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Supplier code</label>
|
|
<input type="text" class="form-control" id="supplierListCode" placeholder="Code">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">SKU</label>
|
|
<input type="text" class="form-control" id="supplierListSku" placeholder="SKU">
|
|
<div class="d-flex gap-2 mt-1">
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="useProductEan()">Brug EAN</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="useProductSku()">Brug SKU</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Pris</label>
|
|
<input type="number" class="form-control" id="supplierListPrice" step="0.01" min="0">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Produktlink</label>
|
|
<input type="text" class="form-control" id="supplierListUrl" placeholder="https://">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Lager</label>
|
|
<input type="number" class="form-control" id="supplierListStock" min="0">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Valuta</label>
|
|
<input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilfoej/Opdater</button>
|
|
</div>
|
|
<div class="col-12">
|
|
<div class="small" id="supplierListMessage"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const productId = {{ product_id }};
|
|
const productDetailState = {
|
|
product: null
|
|
};
|
|
const supplierListState = {
|
|
suppliers: [],
|
|
sort: 'price'
|
|
};
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '').replace(/[&<>"']/g, (ch) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
}[ch]));
|
|
}
|
|
|
|
function formatCurrency(amount) {
|
|
return new Intl.NumberFormat('da-DK', {
|
|
style: 'currency',
|
|
currency: 'DKK',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0
|
|
}).format(amount || 0);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleDateString('da-DK');
|
|
}
|
|
|
|
function setMessage(message, tone = 'text-muted') {
|
|
const el = document.getElementById('priceUpdateMessage');
|
|
if (!el) return;
|
|
el.className = `small ${tone}`;
|
|
el.textContent = message;
|
|
}
|
|
|
|
function setSupplierMessage(message, tone = 'text-muted') {
|
|
const el = document.getElementById('supplierUpdateMessage');
|
|
if (!el) return;
|
|
el.className = `small ${tone}`;
|
|
el.textContent = message;
|
|
}
|
|
|
|
function setSupplierListMessage(message, tone = 'text-muted') {
|
|
const el = document.getElementById('supplierListMessage');
|
|
if (!el) return;
|
|
el.className = `small ${tone}`;
|
|
el.textContent = message;
|
|
}
|
|
|
|
function setProductNameMessage(message, tone = 'text-muted') {
|
|
const el = document.getElementById('productNameMessage');
|
|
if (!el) return;
|
|
el.className = `small ${tone}`;
|
|
el.textContent = message;
|
|
}
|
|
|
|
function updateBestPriceBadge(suppliers) {
|
|
const badge = document.getElementById('productBestPrice');
|
|
if (!badge) return;
|
|
const prices = (suppliers || [])
|
|
.map(supplier => supplier.supplier_price)
|
|
.filter(price => typeof price === 'number');
|
|
if (!prices.length) {
|
|
badge.style.display = 'none';
|
|
return;
|
|
}
|
|
const best = Math.min(...prices);
|
|
badge.textContent = `Bedste pris: ${formatCurrency(best)}`;
|
|
badge.style.display = 'inline-flex';
|
|
}
|
|
|
|
function renderProductInfo(product) {
|
|
const body = document.getElementById('productInfoBody');
|
|
if (!body) return;
|
|
const rows = [
|
|
['EAN', product.ean],
|
|
['Type', product.type],
|
|
['Status', product.status],
|
|
['SKU intern', product.sku_internal],
|
|
['Producent', product.manufacturer],
|
|
['Leverandoer', product.supplier_name],
|
|
['Leverandoer SKU', product.supplier_sku],
|
|
['Leverandoer pris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
|
|
['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null],
|
|
['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null],
|
|
['Moms', product.vat_rate != null ? `${product.vat_rate}%` : null],
|
|
['Faktureringsinterval', product.billing_period],
|
|
['Auto forny', product.auto_renew ? 'Ja' : 'Nej'],
|
|
['Minimum binding', product.minimum_term_months != null ? `${product.minimum_term_months} mdr.` : null]
|
|
];
|
|
body.innerHTML = rows.map(([label, value]) => `
|
|
<tr>
|
|
<th>${label}</th>
|
|
<td>${value != null && value !== '' ? escapeHtml(value) : '-'}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function prefillSupplierSku(product) {
|
|
const skuField = document.getElementById('supplierListSku');
|
|
if (!skuField || skuField.value) return;
|
|
if (product.ean) {
|
|
skuField.value = product.ean;
|
|
} else if (product.sku_internal) {
|
|
skuField.value = product.sku_internal;
|
|
}
|
|
}
|
|
|
|
async function loadProductDetail() {
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}`);
|
|
if (!res.ok) throw new Error('Kunne ikke hente produkt');
|
|
const product = await res.json();
|
|
document.getElementById('productName').textContent = product.name || 'Produkt';
|
|
document.getElementById('productMeta').textContent = [
|
|
product.manufacturer,
|
|
product.type,
|
|
product.supplier_name
|
|
].filter(Boolean).join(' • ');
|
|
document.getElementById('productStatus').textContent = product.status || '-';
|
|
document.getElementById('productPrice').textContent = product.sales_price != null ? formatCurrency(product.sales_price) : '-';
|
|
document.getElementById('productSku').textContent = product.sku_internal || '-';
|
|
document.getElementById('productSupplierPrice').textContent = product.supplier_price != null
|
|
? `Leverandoer: ${formatCurrency(product.supplier_price)}`
|
|
: 'Leverandoer pris: -';
|
|
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
|
|
document.getElementById('supplierName').value = product.supplier_name || '';
|
|
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
|
|
document.getElementById('productNameInput').value = product.name || '';
|
|
productDetailState.product = product;
|
|
renderProductInfo(product);
|
|
prefillSupplierSku(product);
|
|
} catch (e) {
|
|
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
|
|
}
|
|
}
|
|
|
|
async function loadPriceHistory() {
|
|
const tbody = document.getElementById('priceHistoryBody');
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/price-history`);
|
|
if (!res.ok) throw new Error('Kunne ikke hente pris historik');
|
|
const history = await res.json();
|
|
if (!history.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center product-muted py-3">Ingen historik</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = history.map(entry => `
|
|
<tr>
|
|
<td>${formatDate(entry.changed_at)}</td>
|
|
<td>${entry.price_type === 'supplier_price' ? 'Leverandoer' : 'Salgspris'}</td>
|
|
<td>${entry.old_price != null ? formatCurrency(entry.old_price) : '-'}</td>
|
|
<td>${entry.new_price != null ? formatCurrency(entry.new_price) : '-'}</td>
|
|
<td>${escapeHtml(entry.note || '-')}</td>
|
|
<td>${escapeHtml(entry.changed_by || '-')}</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (e) {
|
|
tbody.innerHTML = `<tr><td colspan="6" class="text-center text-danger py-3">${escapeHtml(e.message || 'Fejl')}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
async function loadSalesHistory() {
|
|
const tbody = document.getElementById('salesHistoryBody');
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/sales-history`);
|
|
if (!res.ok) throw new Error('Kunne ikke hente salgs historik');
|
|
const history = await res.json();
|
|
if (!history.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center product-muted py-3">Ingen salg fundet</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = history.map(entry => `
|
|
<tr>
|
|
<td>${formatDate(entry.line_date)}</td>
|
|
<td>${entry.source === 'subscription' ? 'Abonnement' : 'Sag salg'}</td>
|
|
<td>${escapeHtml(entry.description || '-')}</td>
|
|
<td>${entry.quantity != null ? entry.quantity : '-'}</td>
|
|
<td>${entry.unit_price != null ? formatCurrency(entry.unit_price) : '-'}</td>
|
|
<td>${entry.total_amount != null ? formatCurrency(entry.total_amount) : '-'}</td>
|
|
<td>${escapeHtml(entry.status || '-')}</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (e) {
|
|
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${escapeHtml(e.message || 'Fejl')}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
async function loadSupplierList() {
|
|
const tbody = document.getElementById('supplierListBody');
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/suppliers`);
|
|
if (!res.ok) throw new Error('Kunne ikke hente grosister');
|
|
const suppliers = await res.json();
|
|
supplierListState.suppliers = Array.isArray(suppliers) ? suppliers : [];
|
|
updateBestPriceBadge(supplierListState.suppliers);
|
|
const sorted = getSortedSuppliers();
|
|
if (!sorted.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center product-muted py-3">Ingen grosister endnu</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = sorted.map(entry => {
|
|
const link = entry.supplier_product_url || entry.supplier_url;
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td>
|
|
<td>${escapeHtml(entry.supplier_sku || '-')}</td>
|
|
<td>${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'}</td>
|
|
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
|
|
<td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</td>
|
|
<td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}</td>
|
|
<td><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} catch (e) {
|
|
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${escapeHtml(e.message || 'Fejl')}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function getSortedSuppliers() {
|
|
const suppliers = supplierListState.suppliers || [];
|
|
const sort = supplierListState.sort;
|
|
if (sort === 'name') {
|
|
return [...suppliers].sort((a, b) => String(a.supplier_name || '').localeCompare(String(b.supplier_name || '')));
|
|
}
|
|
if (sort === 'stock') {
|
|
return [...suppliers].sort((a, b) => (b.supplier_stock || 0) - (a.supplier_stock || 0));
|
|
}
|
|
if (sort === 'updated') {
|
|
return [...suppliers].sort((a, b) => String(b.last_updated_at || '').localeCompare(String(a.last_updated_at || '')));
|
|
}
|
|
return [...suppliers].sort((a, b) => (a.supplier_price || 0) - (b.supplier_price || 0));
|
|
}
|
|
|
|
function changeSupplierSort(value) {
|
|
supplierListState.sort = value;
|
|
loadSupplierList();
|
|
}
|
|
|
|
async function submitSupplierList() {
|
|
const payload = {
|
|
supplier_name: document.getElementById('supplierListName').value.trim() || null,
|
|
supplier_code: document.getElementById('supplierListCode').value.trim() || null,
|
|
supplier_sku: document.getElementById('supplierListSku').value.trim() || null,
|
|
supplier_price: document.getElementById('supplierListPrice').value ? Number(document.getElementById('supplierListPrice').value) : null,
|
|
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
|
|
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null,
|
|
supplier_product_url: document.getElementById('supplierListUrl').value.trim() || null,
|
|
source: 'manual'
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/suppliers`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Kunne ikke gemme grosist');
|
|
}
|
|
await res.json();
|
|
setSupplierListMessage('Grosist gemt', 'text-success');
|
|
await loadSupplierList();
|
|
} catch (e) {
|
|
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
|
|
}
|
|
}
|
|
|
|
async function deleteSupplier(supplierId) {
|
|
if (!confirm('Vil du slette denne grosist?')) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/suppliers/${supplierId}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Sletning fejlede');
|
|
}
|
|
await res.json();
|
|
await loadSupplierList();
|
|
} catch (e) {
|
|
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
|
|
}
|
|
}
|
|
|
|
function useProductEan() {
|
|
const skuField = document.getElementById('supplierListSku');
|
|
if (!skuField || !productDetailState.product) return;
|
|
skuField.value = productDetailState.product.ean || '';
|
|
}
|
|
|
|
function useProductSku() {
|
|
const skuField = document.getElementById('supplierListSku');
|
|
if (!skuField || !productDetailState.product) return;
|
|
skuField.value = productDetailState.product.sku_internal || '';
|
|
}
|
|
|
|
async function refreshSuppliersFromGateway() {
|
|
setSupplierListMessage('Opdaterer fra Gateway...', 'text-muted');
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/suppliers/refresh`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Opdatering fejlede');
|
|
}
|
|
const payload = await res.json();
|
|
const saved = Number(payload.saved || 0);
|
|
const queries = Array.isArray(payload.queries) ? payload.queries : [];
|
|
const queryText = queries.length
|
|
? ` (forsog: ${queries.map((query) => {
|
|
if (query.supplier_code) {
|
|
return `${query.supplier_code}:${query.q}`;
|
|
}
|
|
return query.q;
|
|
}).join(', ')})`
|
|
: '';
|
|
if (!saved) {
|
|
setSupplierListMessage(`Ingen grosister fundet i Gateway${queryText}`, 'text-warning');
|
|
} else {
|
|
setSupplierListMessage(`Grosister opdateret (${saved})${queryText}`, 'text-success');
|
|
}
|
|
await loadSupplierList();
|
|
} catch (e) {
|
|
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
|
|
}
|
|
}
|
|
|
|
async function updateProductName() {
|
|
const name = document.getElementById('productNameInput').value.trim();
|
|
if (!name) {
|
|
setProductNameMessage('Angiv et navn', 'text-danger');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name })
|
|
});
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Opdatering fejlede');
|
|
}
|
|
await res.json();
|
|
setProductNameMessage('Navn opdateret', 'text-success');
|
|
await loadProductDetail();
|
|
} catch (e) {
|
|
setProductNameMessage(e.message || 'Fejl', 'text-danger');
|
|
}
|
|
}
|
|
|
|
async function deleteProduct() {
|
|
if (!confirm('Vil du slette dette produkt?')) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Sletning fejlede');
|
|
}
|
|
window.location.href = '/products';
|
|
} catch (e) {
|
|
setProductNameMessage(e.message || 'Fejl', 'text-danger');
|
|
}
|
|
}
|
|
|
|
async function submitPriceUpdate() {
|
|
const newPrice = document.getElementById('priceNewValue').value;
|
|
const note = document.getElementById('priceNote').value.trim();
|
|
const changedBy = document.getElementById('priceChangedBy').value.trim();
|
|
|
|
if (!newPrice) {
|
|
setMessage('Angiv ny pris', 'text-danger');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/price`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
new_price: Number(newPrice),
|
|
note: note || null,
|
|
changed_by: changedBy || null
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Prisopdatering fejlede');
|
|
}
|
|
await res.json();
|
|
setMessage('Pris opdateret', 'text-success');
|
|
await loadProductDetail();
|
|
await loadPriceHistory();
|
|
} catch (e) {
|
|
setMessage(e.message || 'Fejl ved opdatering', 'text-danger');
|
|
}
|
|
}
|
|
|
|
async function submitSupplierUpdate() {
|
|
const supplierName = document.getElementById('supplierName').value.trim();
|
|
const supplierPriceValue = document.getElementById('supplierPrice').value;
|
|
const note = document.getElementById('supplierNote').value.trim();
|
|
const changedBy = document.getElementById('supplierChangedBy').value.trim();
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/products/${productId}/supplier`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
supplier_name: supplierName || null,
|
|
supplier_price: supplierPriceValue ? Number(supplierPriceValue) : null,
|
|
note: note || null,
|
|
changed_by: changedBy || null
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || 'Leverandoer opdatering fejlede');
|
|
}
|
|
await res.json();
|
|
setSupplierMessage('Leverandoer opdateret', 'text-success');
|
|
await loadProductDetail();
|
|
await loadPriceHistory();
|
|
} catch (e) {
|
|
setSupplierMessage(e.message || 'Fejl ved opdatering', 'text-danger');
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadProductDetail();
|
|
loadPriceHistory();
|
|
loadSalesHistory();
|
|
loadSupplierList();
|
|
});
|
|
</script>
|
|
{% endblock %}
|