bmc_hub/app/modules/webshop/frontend/index.html
Christian f059cb6c95 feat: Add product search endpoint and enhance opportunity management
- Implemented a new endpoint for searching webshop products with filters for visibility and configuration.
- Enhanced the webshop frontend to include a customer search feature for improved user experience.
- Added opportunity line items management with CRUD operations and comments functionality.
- Created database migrations for opportunity line items and comments, including necessary triggers and indexes.
2026-01-28 14:37:47 +01:00

669 lines
29 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Webshop Administration - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.webshop-card {
transition: all 0.2s;
border: 1px solid rgba(0,0,0,0.1);
}
.webshop-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.status-badge {
padding: 0.375rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.color-preview {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.1);
}
.form-label-required::after {
content: " *";
color: #dc3545;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5">
<div>
<h2 class="fw-bold mb-1">Webshop Administration</h2>
<p class="text-muted mb-0">Administrer kunde-webshops og konfigurationer</p>
</div>
<div class="d-flex gap-3">
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Webshop
</button>
</div>
</div>
<div class="row g-4" id="webshopsGrid">
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="webshopModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">Opret Webshop</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="webshopForm">
<input type="hidden" id="configId">
<!-- Kunde Search -->
<div class="mb-3">
<label for="customerSearch" class="form-label">Søg kunde</label>
<input type="search" class="form-control" id="customerSearch"
placeholder="Søg efter navn, CVR eller email">
<small class="form-text text-muted">
Begynd at skrive for at indsnævre listen og find den rigtige kunde hurtigt.
</small>
</div>
<!-- Kunde Selection -->
<div class="mb-3">
<label for="customerId" class="form-label form-label-required">Kunde</label>
<select class="form-select" id="customerId" required>
<option value="">Vælg kunde...</option>
</select>
</div>
<!-- Webshop Navn -->
<div class="mb-3">
<label for="webshopName" class="form-label form-label-required">Webshop Navn</label>
<input type="text" class="form-control" id="webshopName" required
placeholder="fx 'Advokatfirmaet A/S Webshop'">
</div>
<!-- Email Domæner -->
<div class="mb-3">
<label for="emailDomains" class="form-label form-label-required">Tilladte Email Domæner</label>
<input type="text" class="form-control" id="emailDomains" required
placeholder="fx 'firma.dk,firma.com' (komma-separeret)">
<small class="form-text text-muted">Kun brugere med disse email-domæner kan logge ind</small>
</div>
<!-- Header & Intro Text -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="headerText" class="form-label">Header Tekst</label>
<input type="text" class="form-control" id="headerText"
placeholder="fx 'Velkommen til vores webshop'">
</div>
<div class="col-md-6 mb-3">
<label for="introText" class="form-label">Intro Tekst</label>
<textarea class="form-control" id="introText" rows="2"
placeholder="Kort introduktion til webshoppen"></textarea>
</div>
</div>
<!-- Colors -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="primaryColor" class="form-label">Primær Farve</label>
<div class="input-group">
<input type="color" class="form-control form-control-color"
id="primaryColor" value="#0f4c75">
<input type="text" class="form-control" id="primaryColorHex"
value="#0f4c75" maxlength="7">
</div>
</div>
<div class="col-md-6 mb-3">
<label for="accentColor" class="form-label">Accent Farve</label>
<div class="input-group">
<input type="color" class="form-control form-control-color"
id="accentColor" value="#3282b8">
<input type="text" class="form-control" id="accentColorHex"
value="#3282b8" maxlength="7">
</div>
</div>
</div>
<!-- Pricing -->
<div class="row">
<div class="col-md-4 mb-3">
<label for="defaultMargin" class="form-label">Standard Avance (%)</label>
<input type="number" class="form-control" id="defaultMargin"
value="10" min="0" max="100" step="0.1">
</div>
<div class="col-md-4 mb-3">
<label for="minOrderAmount" class="form-label">Min. Ordre Beløb (DKK)</label>
<input type="number" class="form-control" id="minOrderAmount"
value="0" min="0" step="0.01">
</div>
<div class="col-md-4 mb-3">
<label for="shippingCost" class="form-label">Forsendelse (DKK)</label>
<input type="number" class="form-control" id="shippingCost"
value="0" min="0" step="0.01">
</div>
</div>
<!-- Enabled -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enabled" checked>
<label class="form-check-label" for="enabled">
Webshop aktiveret
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveWebshop()">
<i class="bi bi-check-lg me-2"></i>Gem Webshop
</button>
</div>
</div>
</div>
</div>
<!-- Products Modal -->
<div class="modal fade" id="productsModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Produkter - <span id="productsModalWebshopName"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="mb-0 text-muted">Administrer tilladte produkter for denne webshop</p>
</div>
<button class="btn btn-sm btn-primary" onclick="openAddProductModal()">
<i class="bi bi-plus-lg me-2"></i>Tilføj Produkt
</button>
</div>
<input type="hidden" id="currentConfigId">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Varenr</th>
<th>Navn</th>
<th>EAN</th>
<th>Basispris</th>
<th>Avance %</th>
<th>Salgspris</th>
<th>Synlig</th>
<th></th>
</tr>
</thead>
<tbody id="productsTableBody">
<tr>
<td colspan="8" class="text-center py-4 text-muted">Ingen produkter endnu</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<!-- Add Product Modal -->
<div class="modal fade" id="addProductModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tilføj Produkt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addProductForm">
<div class="mb-3">
<label for="productNumber" class="form-label form-label-required">Varenummer</label>
<input type="text" class="form-control" id="productNumber" required>
</div>
<div class="mb-3">
<label for="productEan" class="form-label">EAN</label>
<input type="text" class="form-control" id="productEan">
</div>
<div class="mb-3">
<label for="productName" class="form-label form-label-required">Navn</label>
<input type="text" class="form-control" id="productName" required>
</div>
<div class="mb-3">
<label for="productDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="productDescription" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="productBasePrice" class="form-label form-label-required">Basispris (DKK)</label>
<input type="number" class="form-control" id="productBasePrice" required step="0.01">
</div>
<div class="col-md-6 mb-3">
<label for="productCustomMargin" class="form-label">Custom Avance (%)</label>
<input type="number" class="form-control" id="productCustomMargin" step="0.1" placeholder="Standard bruges">
</div>
</div>
<div class="mb-3">
<label for="productCategory" class="form-label">Kategori</label>
<input type="text" class="form-control" id="productCategory" placeholder="fx 'Network Security'">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="addProduct()">
<i class="bi bi-plus-lg me-2"></i>Tilføj
</button>
</div>
</div>
</div>
</div>
<script>
let webshopsData = [];
let currentWebshopConfig = null;
let webshopModal, productsModal, addProductModal;
let customerSearchTimeout;
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize Bootstrap modals after DOM is loaded
webshopModal = new bootstrap.Modal(document.getElementById('webshopModal'));
productsModal = new bootstrap.Modal(document.getElementById('productsModal'));
addProductModal = new bootstrap.Modal(document.getElementById('addProductModal'));
loadWebshops();
loadCustomers();
initCustomerSearch();
// Color picker sync
document.getElementById('primaryColor').addEventListener('input', (e) => {
document.getElementById('primaryColorHex').value = e.target.value;
});
document.getElementById('primaryColorHex').addEventListener('input', (e) => {
document.getElementById('primaryColor').value = e.target.value;
});
document.getElementById('accentColor').addEventListener('input', (e) => {
document.getElementById('accentColorHex').value = e.target.value;
});
document.getElementById('accentColorHex').addEventListener('input', (e) => {
document.getElementById('accentColor').value = e.target.value;
});
});
async function loadWebshops() {
try {
const response = await fetch('/api/v1/webshop/configs');
const data = await response.json();
if (data.success) {
webshopsData = data.configs;
renderWebshops();
}
} catch (error) {
console.error('Error loading webshops:', error);
showToast('Fejl ved indlæsning af webshops', 'danger');
}
}
function renderWebshops() {
const grid = document.getElementById('webshopsGrid');
if (webshopsData.length === 0) {
grid.innerHTML = `
<div class="col-12 text-center py-5">
<i class="bi bi-shop display-1 text-muted mb-3"></i>
<p class="text-muted">Ingen webshops oprettet endnu</p>
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Din Første Webshop
</button>
</div>
`;
return;
}
grid.innerHTML = webshopsData.map(ws => `
<div class="col-md-6 col-lg-4">
<div class="card webshop-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-1">${ws.name}</h5>
<small class="text-muted">${ws.customer_name || 'Ingen kunde'}</small>
</div>
<span class="status-badge ${ws.enabled ? 'bg-success bg-opacity-10 text-success' : 'bg-danger bg-opacity-10 text-danger'}">
${ws.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div class="mb-3">
<small class="text-muted d-block mb-1">Branding</small>
<div class="d-flex gap-2">
<div class="color-preview" style="background-color: ${ws.primary_color}"></div>
<div class="color-preview" style="background-color: ${ws.accent_color}"></div>
</div>
</div>
<div class="mb-3">
<small class="text-muted d-block mb-1">Email Domæner</small>
<div class="small">${ws.allowed_email_domains}</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<small class="text-muted d-block">Produkter</small>
<strong>${ws.product_count || 0}</strong>
</div>
<div class="col-6">
<small class="text-muted d-block">Avance</small>
<strong>${ws.default_margin_percent}%</strong>
</div>
</div>
${ws.last_published_at ? `
<div class="mb-3">
<small class="text-muted">Sidst publiceret: ${new Date(ws.last_published_at).toLocaleString('da-DK')}</small>
</div>
` : '<div class="mb-3"><small class="text-warning">⚠️ Ikke publiceret endnu</small></div>'}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-fill" onclick="openEditModal(${ws.id})">
<i class="bi bi-pencil me-1"></i>Rediger
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="openProductsModal(${ws.id})">
<i class="bi bi-box-seam"></i>
</button>
<button class="btn btn-sm btn-success" onclick="publishWebshop(${ws.id})"
${!ws.enabled ? 'disabled' : ''}>
<i class="bi bi-cloud-upload"></i>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
function initCustomerSearch() {
const searchInput = document.getElementById('customerSearch');
if (!searchInput) return;
searchInput.addEventListener('input', (event) => {
const term = event.target.value.trim();
clearTimeout(customerSearchTimeout);
customerSearchTimeout = setTimeout(() => loadCustomers(term), 300);
});
}
async function loadCustomers(searchTerm = '') {
try {
const params = new URLSearchParams({ limit: '1000' });
if (searchTerm) {
params.set('search', searchTerm);
}
const response = await fetch(`/api/v1/customers?${params.toString()}`);
const data = await response.json();
const customers = Array.isArray(data) ? data : (data.customers || []);
const select = document.getElementById('customerId');
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
} catch (error) {
console.error('Error loading customers:', error);
}
}
async function openCreateModal() {
document.getElementById('modalTitle').textContent = 'Opret Webshop';
document.getElementById('webshopForm').reset();
document.getElementById('configId').value = '';
document.getElementById('enabled').checked = true;
const searchInput = document.getElementById('customerSearch');
if (searchInput) searchInput.value = '';
await loadCustomers();
webshopModal.show();
}
async function openEditModal(configId) {
const ws = webshopsData.find(w => w.id === configId);
if (!ws) return;
document.getElementById('modalTitle').textContent = 'Rediger Webshop';
document.getElementById('configId').value = ws.id;
const searchInput = document.getElementById('customerSearch');
if (searchInput) searchInput.value = '';
await loadCustomers();
document.getElementById('customerId').value = ws.customer_id;
document.getElementById('webshopName').value = ws.name;
document.getElementById('emailDomains').value = ws.allowed_email_domains;
document.getElementById('headerText').value = ws.header_text || '';
document.getElementById('introText').value = ws.intro_text || '';
document.getElementById('primaryColor').value = ws.primary_color;
document.getElementById('primaryColorHex').value = ws.primary_color;
document.getElementById('accentColor').value = ws.accent_color;
document.getElementById('accentColorHex').value = ws.accent_color;
document.getElementById('defaultMargin').value = ws.default_margin_percent;
document.getElementById('minOrderAmount').value = ws.min_order_amount;
document.getElementById('shippingCost').value = ws.shipping_cost;
document.getElementById('enabled').checked = ws.enabled;
webshopModal.show();
}
async function saveWebshop() {
const configId = document.getElementById('configId').value;
const isEdit = !!configId;
const payload = {
customer_id: parseInt(document.getElementById('customerId').value),
name: document.getElementById('webshopName').value,
allowed_email_domains: document.getElementById('emailDomains').value,
header_text: document.getElementById('headerText').value || null,
intro_text: document.getElementById('introText').value || null,
primary_color: document.getElementById('primaryColorHex').value,
accent_color: document.getElementById('accentColorHex').value,
default_margin_percent: parseFloat(document.getElementById('defaultMargin').value),
min_order_amount: parseFloat(document.getElementById('minOrderAmount').value),
shipping_cost: parseFloat(document.getElementById('shippingCost').value),
enabled: document.getElementById('enabled').checked
};
try {
const url = isEdit ? `/api/v1/webshop/configs/${configId}` : '/api/v1/webshop/configs';
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Webshop gemt', 'success');
webshopModal.hide();
loadWebshops();
} else {
showToast(data.message || 'Fejl ved gemning', 'danger');
}
} catch (error) {
console.error('Error saving webshop:', error);
showToast('Fejl ved gemning af webshop', 'danger');
}
}
async function openProductsModal(configId) {
currentWebshopConfig = configId;
document.getElementById('currentConfigId').value = configId;
const ws = webshopsData.find(w => w.id === configId);
document.getElementById('productsModalWebshopName').textContent = ws ? ws.name : '';
productsModal.show();
loadProducts(configId);
}
async function loadProducts(configId) {
try {
const response = await fetch(`/api/v1/webshop/configs/${configId}`);
const data = await response.json();
if (data.success) {
renderProducts(data.products, data.config.default_margin_percent);
}
} catch (error) {
console.error('Error loading products:', error);
showToast('Fejl ved indlæsning af produkter', 'danger');
}
}
function renderProducts(products, defaultMargin) {
const tbody = document.getElementById('productsTableBody');
if (!products || products.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">Ingen produkter endnu</td></tr>';
return;
}
tbody.innerHTML = products.map(p => {
const margin = p.custom_margin_percent || defaultMargin;
const salePrice = p.base_price * (1 + margin / 100);
return `
<tr>
<td><code>${p.product_number}</code></td>
<td>${p.name}</td>
<td>${p.ean || '-'}</td>
<td>${p.base_price.toFixed(2)} kr</td>
<td>${margin.toFixed(1)}%</td>
<td><strong>${salePrice.toFixed(2)} kr</strong></td>
<td>
<span class="badge ${p.visible ? 'bg-success' : 'bg-secondary'}">
${p.visible ? 'Ja' : 'Nej'}
</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="removeProduct(${p.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
}
function openAddProductModal() {
document.getElementById('addProductForm').reset();
addProductModal.show();
}
async function addProduct() {
const configId = document.getElementById('currentConfigId').value;
const payload = {
webshop_config_id: parseInt(configId),
product_number: document.getElementById('productNumber').value,
ean: document.getElementById('productEan').value || null,
name: document.getElementById('productName').value,
description: document.getElementById('productDescription').value || null,
base_price: parseFloat(document.getElementById('productBasePrice').value),
custom_margin_percent: document.getElementById('productCustomMargin').value ?
parseFloat(document.getElementById('productCustomMargin').value) : null,
category: document.getElementById('productCategory').value || null,
visible: true,
sort_order: 0
};
try {
const response = await fetch('/api/v1/webshop/products', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast('Produkt tilføjet', 'success');
addProductModal.hide();
loadProducts(configId);
} else {
showToast(data.detail || 'Fejl ved tilføjelse', 'danger');
}
} catch (error) {
console.error('Error adding product:', error);
showToast('Fejl ved tilføjelse af produkt', 'danger');
}
}
async function removeProduct(productId) {
if (!confirm('Er du sikker på at du vil fjerne dette produkt?')) return;
try {
const response = await fetch(`/api/v1/webshop/products/${productId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast('Produkt fjernet', 'success');
const configId = document.getElementById('currentConfigId').value;
loadProducts(configId);
}
} catch (error) {
console.error('Error removing product:', error);
showToast('Fejl ved fjernelse', 'danger');
}
}
async function publishWebshop(configId) {
if (!confirm('Vil du publicere denne webshop til Gateway?')) return;
try {
const response = await fetch(`/api/v1/webshop/configs/${configId}/publish`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast('Webshop publiceret til Gateway!', 'success');
loadWebshops();
} else {
showToast(data.detail || 'Fejl ved publicering', 'danger');
}
} catch (error) {
console.error('Error publishing webshop:', error);
showToast('Fejl ved publicering', 'danger');
}
}
function showToast(message, type = 'info') {
// Reuse existing toast system if available
console.log(`[${type.toUpperCase()}] ${message}`);
alert(message); // Replace with proper toast when available
}
</script>
{% endblock %}