2025-12-06 02:22:01 +01:00
|
|
|
{% extends "shared/frontend/base.html" %}
|
|
|
|
|
|
|
|
|
|
{% block title %}Kunder - BMC Hub{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block extra_css %}
|
|
|
|
|
<style>
|
2026-04-01 21:34:58 +02:00
|
|
|
.customers-toolbar {
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toolbar-search-slot {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-wrap {
|
|
|
|
|
position: relative;
|
|
|
|
|
min-width: 280px;
|
|
|
|
|
max-width: 460px;
|
|
|
|
|
width: min(46vw, 460px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-wrap .header-search {
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding-right: 2.4rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-clear {
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: 0.45rem;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
border: 0;
|
|
|
|
|
width: 1.8rem;
|
|
|
|
|
height: 1.8rem;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
background: transparent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-clear:hover {
|
|
|
|
|
background: rgba(15, 76, 117, 0.12);
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-clear.d-none {
|
|
|
|
|
display: none !important;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 02:22:01 +01:00
|
|
|
.filter-btn {
|
|
|
|
|
background: var(--bg-card);
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
padding: 0.5rem 1.2rem;
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.filter-btn:hover, .filter-btn.active {
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: white;
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
}
|
2026-04-01 21:34:58 +02:00
|
|
|
|
|
|
|
|
.lookup-status {
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 992px) {
|
|
|
|
|
.customers-toolbar {
|
|
|
|
|
width: 100%;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: stretch !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toolbar-search-slot {
|
|
|
|
|
width: 100%;
|
|
|
|
|
justify-content: stretch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-wrap {
|
|
|
|
|
width: 100%;
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-06 02:22:01 +01:00
|
|
|
</style>
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
2026-04-01 21:34:58 +02:00
|
|
|
<div class="d-flex justify-content-between align-items-center mb-5 customers-toolbar">
|
2025-12-06 02:22:01 +01:00
|
|
|
<div>
|
|
|
|
|
<h2 class="fw-bold mb-1">Kunder</h2>
|
|
|
|
|
<p class="text-muted mb-0">Administrer dine kunder</p>
|
|
|
|
|
</div>
|
2026-04-01 21:34:58 +02:00
|
|
|
<div class="toolbar-search-slot">
|
|
|
|
|
<div class="search-wrap">
|
|
|
|
|
<input type="search" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, kontakt eller e-mail..." autocomplete="off" spellcheck="false">
|
|
|
|
|
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
|
|
|
|
|
<i class="bi bi-x-lg"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-12-06 02:22:01 +01:00
|
|
|
</div>
|
2026-04-01 21:34:58 +02:00
|
|
|
<button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal">
|
|
|
|
|
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
|
|
|
|
|
</button>
|
2025-12-06 02:22:01 +01:00
|
|
|
</div>
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
<div class="mb-4 d-flex gap-2">
|
2026-04-01 21:34:58 +02:00
|
|
|
<button class="filter-btn active" data-filter="all" type="button">Alle Kunder</button>
|
|
|
|
|
<button class="filter-btn" data-filter="active" type="button">Aktive</button>
|
|
|
|
|
<button class="filter-btn" data-filter="inactive" type="button">Inaktive</button>
|
|
|
|
|
<button class="filter-btn" data-filter="vip" type="button">VIP</button>
|
2025-12-06 02:22:01 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card p-4">
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table table-hover align-middle">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Virksomhed</th>
|
2025-12-16 15:36:11 +01:00
|
|
|
<th>Kontakt</th>
|
2025-12-06 02:22:01 +01:00
|
|
|
<th>CVR</th>
|
|
|
|
|
<th>Status</th>
|
2025-12-16 15:36:11 +01:00
|
|
|
<th>E-mail</th>
|
2025-12-06 02:22:01 +01:00
|
|
|
<th class="text-end">Handlinger</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="customersTableBody">
|
|
|
|
|
<tr>
|
2025-12-16 15:36:11 +01:00
|
|
|
<td colspan="6" class="text-center py-5">
|
2025-12-06 02:22:01 +01:00
|
|
|
<div class="spinner-border text-primary" role="status">
|
|
|
|
|
<span class="visually-hidden">Loading...</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
2025-12-16 15:36:11 +01:00
|
|
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
|
|
|
|
<div class="text-muted" id="customerCount">Loading...</div>
|
|
|
|
|
<nav>
|
|
|
|
|
<ul class="pagination mb-0" id="pagination"></ul>
|
|
|
|
|
</nav>
|
2025-12-06 02:22:01 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-01 21:34:58 +02:00
|
|
|
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
|
|
|
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title" id="createCustomerModalLabel">Opret ny kunde</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<form id="createCustomerForm">
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
<div class="row g-3">
|
|
|
|
|
<div class="col-md-4">
|
|
|
|
|
<label class="form-label" for="createCustomerCvr">CVR</label>
|
|
|
|
|
<div class="input-group">
|
|
|
|
|
<input type="text" class="form-control" id="createCustomerCvr" placeholder="fx 24256790" inputmode="numeric" maxlength="8">
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" id="lookupCvrBtn">Hent</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="lookup-status mt-1" id="lookupCvrStatus">Indtast CVR og klik Hent for autofyld.</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-8">
|
|
|
|
|
<label class="form-label" for="createCustomerName">Virksomhedsnavn *</label>
|
|
|
|
|
<input type="text" class="form-control" id="createCustomerName" required>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<label class="form-label" for="createCustomerEmail">E-mail</label>
|
|
|
|
|
<input type="email" class="form-control" id="createCustomerEmail">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<label class="form-label" for="createCustomerInvoiceEmail">Faktura e-mail</label>
|
|
|
|
|
<input type="email" class="form-control" id="createCustomerInvoiceEmail">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<label class="form-label" for="createCustomerPhone">Telefon</label>
|
|
|
|
|
<input type="text" class="form-control" id="createCustomerPhone">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<label class="form-label" for="createCustomerWebsite">Website</label>
|
|
|
|
|
<input type="url" class="form-control" id="createCustomerWebsite" placeholder="https://...">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-8">
|
|
|
|
|
<label class="form-label" for="createCustomerAddress">Adresse</label>
|
|
|
|
|
<input type="text" class="form-control" id="createCustomerAddress">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-2">
|
|
|
|
|
<label class="form-label" for="createCustomerPostalCode">Postnr.</label>
|
|
|
|
|
<input type="text" class="form-control" id="createCustomerPostalCode">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-2">
|
|
|
|
|
<label class="form-label" for="createCustomerCity">By</label>
|
|
|
|
|
<input type="text" class="form-control" id="createCustomerCity">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-4">
|
|
|
|
|
<label class="form-label" for="createCustomerCountry">Land</label>
|
|
|
|
|
<input type="text" class="form-control" id="createCustomerCountry" value="DK">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-8 d-flex align-items-end">
|
|
|
|
|
<div class="form-check form-switch">
|
|
|
|
|
<input class="form-check-input" type="checkbox" role="switch" id="createCustomerIsActive" checked>
|
|
|
|
|
<label class="form-check-label" for="createCustomerIsActive">Kunden er aktiv</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-footer">
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
|
|
|
<button type="submit" class="btn btn-primary" id="createCustomerSubmitBtn">
|
|
|
|
|
<span class="submit-label">Opret kunde</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-06 02:22:01 +01:00
|
|
|
<script>
|
2025-12-16 15:36:11 +01:00
|
|
|
let currentPage = 1;
|
|
|
|
|
const pageSize = 50;
|
2025-12-06 02:22:01 +01:00
|
|
|
let totalCustomers = 0;
|
2025-12-16 15:36:11 +01:00
|
|
|
let searchTerm = '';
|
|
|
|
|
let searchTimeout = null;
|
2026-04-01 21:34:58 +02:00
|
|
|
let currentRequestController = null;
|
|
|
|
|
let lastLoadedQueryKey = '';
|
|
|
|
|
let createCustomerModal = null;
|
|
|
|
|
let activeFilter = 'all';
|
2025-12-06 02:22:01 +01:00
|
|
|
|
|
|
|
|
// Load customers on page load
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
loadCustomers();
|
2026-04-01 21:34:58 +02:00
|
|
|
createCustomerModal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
// Setup search with debounce
|
2025-12-13 12:06:28 +01:00
|
|
|
const searchInput = document.getElementById('searchInput');
|
2026-04-01 21:34:58 +02:00
|
|
|
const clearBtn = document.getElementById('searchClearBtn');
|
|
|
|
|
|
|
|
|
|
const triggerSearch = () => {
|
|
|
|
|
const nextSearchTerm = searchInput.value.trim();
|
|
|
|
|
if (nextSearchTerm === searchTerm) {
|
|
|
|
|
toggleClearButton(nextSearchTerm);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchTerm = nextSearchTerm;
|
|
|
|
|
toggleClearButton(searchTerm);
|
|
|
|
|
loadCustomers(1);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
searchInput.addEventListener('input', (e) => {
|
2025-12-06 02:22:01 +01:00
|
|
|
clearTimeout(searchTimeout);
|
2026-04-01 21:34:58 +02:00
|
|
|
toggleClearButton(e.target.value.trim());
|
2025-12-06 02:22:01 +01:00
|
|
|
searchTimeout = setTimeout(() => {
|
2026-04-01 21:34:58 +02:00
|
|
|
triggerSearch();
|
2025-12-06 02:22:01 +01:00
|
|
|
}, 300);
|
|
|
|
|
});
|
2026-04-01 21:34:58 +02:00
|
|
|
|
|
|
|
|
searchInput.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
clearTimeout(searchTimeout);
|
|
|
|
|
triggerSearch();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
if (!searchInput.value) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
searchInput.value = '';
|
|
|
|
|
clearTimeout(searchTimeout);
|
|
|
|
|
triggerSearch();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearBtn.addEventListener('click', () => {
|
|
|
|
|
if (!searchInput.value) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchInput.value = '';
|
|
|
|
|
clearTimeout(searchTimeout);
|
|
|
|
|
triggerSearch();
|
|
|
|
|
searchInput.focus();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('createCustomerForm').addEventListener('submit', createCustomer);
|
|
|
|
|
document.getElementById('lookupCvrBtn').addEventListener('click', lookupCvrAndAutofill);
|
|
|
|
|
document.getElementById('createCustomerCvr').addEventListener('input', onCvrInput);
|
|
|
|
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
|
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
const nextFilter = btn.dataset.filter || 'all';
|
|
|
|
|
if (nextFilter === activeFilter) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activeFilter = nextFilter;
|
|
|
|
|
syncFilterButtons();
|
|
|
|
|
lastLoadedQueryKey = '';
|
|
|
|
|
loadCustomers(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('createCustomerModal').addEventListener('hidden.bs.modal', () => {
|
|
|
|
|
resetCreateCustomerForm();
|
|
|
|
|
});
|
2025-12-06 02:22:01 +01:00
|
|
|
});
|
|
|
|
|
|
2026-04-01 21:34:58 +02:00
|
|
|
function onCvrInput(e) {
|
|
|
|
|
const digits = String(e.target.value || '').replace(/\D/g, '').slice(0, 8);
|
|
|
|
|
e.target.value = digits;
|
|
|
|
|
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setLookupStatus(message, isError = false) {
|
|
|
|
|
const status = document.getElementById('lookupCvrStatus');
|
|
|
|
|
status.textContent = message;
|
|
|
|
|
status.classList.toggle('text-danger', isError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function lookupCvrAndAutofill() {
|
|
|
|
|
const cvrInput = document.getElementById('createCustomerCvr');
|
|
|
|
|
const lookupBtn = document.getElementById('lookupCvrBtn');
|
|
|
|
|
const cvr = String(cvrInput.value || '').replace(/\D/g, '');
|
|
|
|
|
|
|
|
|
|
if (cvr.length !== 8) {
|
|
|
|
|
setLookupStatus('CVR skal være præcis 8 cifre.', true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lookupBtn.disabled = true;
|
|
|
|
|
lookupBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
|
|
|
|
|
setLookupStatus('Henter data fra FirmaAPI...', false);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/cvr/${cvr}`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
if (response.status === 404) {
|
|
|
|
|
setLookupStatus('CVR blev ikke fundet.', true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`HTTP ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
applyCustomerAutofill(data || {});
|
|
|
|
|
setLookupStatus('CVR-data hentet og felter autofyldt.', false);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('CVR lookup failed:', error);
|
|
|
|
|
setLookupStatus(`Kunne ikke hente CVR-data: ${error.message}`, true);
|
|
|
|
|
} finally {
|
|
|
|
|
lookupBtn.disabled = false;
|
|
|
|
|
lookupBtn.textContent = 'Hent';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyCustomerAutofill(data) {
|
|
|
|
|
if (data.name) document.getElementById('createCustomerName').value = data.name;
|
|
|
|
|
if (data.email) document.getElementById('createCustomerEmail').value = data.email;
|
|
|
|
|
if (data.phone) document.getElementById('createCustomerPhone').value = data.phone;
|
|
|
|
|
if (data.address) document.getElementById('createCustomerAddress').value = data.address;
|
|
|
|
|
if (data.city) document.getElementById('createCustomerCity').value = data.city;
|
|
|
|
|
if (data.postal_code || data.zipcode) {
|
|
|
|
|
document.getElementById('createCustomerPostalCode').value = data.postal_code || data.zipcode;
|
|
|
|
|
}
|
|
|
|
|
if (data.country) document.getElementById('createCustomerCountry').value = data.country;
|
|
|
|
|
if (data.website) document.getElementById('createCustomerWebsite').value = data.website;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildCreateCustomerPayload() {
|
|
|
|
|
const email = document.getElementById('createCustomerEmail').value.trim();
|
|
|
|
|
const domain = email.includes('@') ? email.split('@').pop().toLowerCase() : null;
|
|
|
|
|
|
|
|
|
|
const cleanValue = (id) => {
|
|
|
|
|
const value = document.getElementById(id).value.trim();
|
|
|
|
|
return value || null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
name: document.getElementById('createCustomerName').value.trim(),
|
|
|
|
|
cvr_number: cleanValue('createCustomerCvr'),
|
|
|
|
|
email: email || null,
|
|
|
|
|
email_domain: domain,
|
|
|
|
|
phone: cleanValue('createCustomerPhone'),
|
|
|
|
|
address: cleanValue('createCustomerAddress'),
|
|
|
|
|
city: cleanValue('createCustomerCity'),
|
|
|
|
|
postal_code: cleanValue('createCustomerPostalCode'),
|
|
|
|
|
country: cleanValue('createCustomerCountry') || 'DK',
|
|
|
|
|
website: cleanValue('createCustomerWebsite'),
|
|
|
|
|
is_active: document.getElementById('createCustomerIsActive').checked,
|
|
|
|
|
invoice_email: cleanValue('createCustomerInvoiceEmail'),
|
|
|
|
|
mobile_phone: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createCustomer(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
const submitBtn = document.getElementById('createCustomerSubmitBtn');
|
|
|
|
|
const submitLabel = submitBtn.querySelector('.submit-label');
|
|
|
|
|
const payload = buildCreateCustomerPayload();
|
|
|
|
|
|
|
|
|
|
if (!payload.name) {
|
|
|
|
|
setLookupStatus('Virksomhedsnavn er påkrævet.', true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
submitBtn.disabled = true;
|
|
|
|
|
submitLabel.textContent = 'Opretter...';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/v1/customers', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorText = await response.text();
|
|
|
|
|
throw new Error(errorText || `HTTP ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const created = await response.json();
|
|
|
|
|
createCustomerModal.hide();
|
|
|
|
|
searchTerm = '';
|
|
|
|
|
document.getElementById('searchInput').value = '';
|
|
|
|
|
toggleClearButton('');
|
|
|
|
|
lastLoadedQueryKey = '';
|
|
|
|
|
await loadCustomers(1);
|
|
|
|
|
|
|
|
|
|
if (created && created.id) {
|
|
|
|
|
window.location.href = `/customers/${created.id}`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to create customer:', error);
|
|
|
|
|
setLookupStatus(`Oprettelse fejlede: ${error.message}`, true);
|
|
|
|
|
} finally {
|
|
|
|
|
submitBtn.disabled = false;
|
|
|
|
|
submitLabel.textContent = 'Opret kunde';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetCreateCustomerForm() {
|
|
|
|
|
const form = document.getElementById('createCustomerForm');
|
|
|
|
|
form.reset();
|
|
|
|
|
document.getElementById('createCustomerCountry').value = 'DK';
|
|
|
|
|
document.getElementById('createCustomerIsActive').checked = true;
|
|
|
|
|
setLookupStatus('Indtast CVR og klik Hent for autofyld.', false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncFilterButtons() {
|
|
|
|
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((btn) => {
|
|
|
|
|
btn.classList.toggle('active', btn.dataset.filter === activeFilter);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
async function loadCustomers(page = 1) {
|
|
|
|
|
currentPage = page;
|
|
|
|
|
const offset = (page - 1) * pageSize;
|
2026-04-01 21:34:58 +02:00
|
|
|
|
|
|
|
|
if (currentRequestController) {
|
|
|
|
|
currentRequestController.abort();
|
|
|
|
|
}
|
|
|
|
|
currentRequestController = new AbortController();
|
|
|
|
|
|
2025-12-06 02:22:01 +01:00
|
|
|
try {
|
2025-12-16 15:36:11 +01:00
|
|
|
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
|
|
|
|
|
if (searchTerm) {
|
|
|
|
|
url += `&search=${encodeURIComponent(searchTerm)}`;
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
2026-04-01 21:34:58 +02:00
|
|
|
|
|
|
|
|
if (activeFilter === 'active') {
|
|
|
|
|
url += '&is_active=true';
|
|
|
|
|
} else if (activeFilter === 'inactive') {
|
|
|
|
|
url += '&is_active=false';
|
|
|
|
|
} else if (activeFilter === 'vip') {
|
|
|
|
|
url += '&vip=true';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const queryKey = `${page}|${searchTerm}|${activeFilter}`;
|
|
|
|
|
if (queryKey === lastLoadedQueryKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, { signal: currentRequestController.signal });
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`HTTP ${response.status}`);
|
|
|
|
|
}
|
2025-12-06 02:22:01 +01:00
|
|
|
const data = await response.json();
|
2026-04-01 21:34:58 +02:00
|
|
|
|
|
|
|
|
lastLoadedQueryKey = queryKey;
|
2025-12-06 02:22:01 +01:00
|
|
|
totalCustomers = data.total;
|
2025-12-16 15:36:11 +01:00
|
|
|
renderCustomers(data.customers);
|
|
|
|
|
renderPagination();
|
|
|
|
|
updateCount();
|
2026-04-01 21:34:58 +02:00
|
|
|
|
2025-12-06 02:22:01 +01:00
|
|
|
} catch (error) {
|
2026-04-01 21:34:58 +02:00
|
|
|
if (error.name === 'AbortError') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-16 15:36:11 +01:00
|
|
|
console.error('Error loading customers:', error);
|
|
|
|
|
document.getElementById('customersTableBody').innerHTML = `
|
|
|
|
|
<tr><td colspan="6" class="text-center text-danger py-5">
|
|
|
|
|
❌ Fejl ved indlæsning: ${error.message}
|
|
|
|
|
</td></tr>
|
|
|
|
|
`;
|
2026-04-01 21:34:58 +02:00
|
|
|
} finally {
|
|
|
|
|
currentRequestController = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleClearButton(value) {
|
|
|
|
|
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(value) {
|
|
|
|
|
if (value === null || value === undefined) {
|
|
|
|
|
return '-';
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
2026-04-01 21:34:58 +02:00
|
|
|
|
|
|
|
|
return String(value)
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
function renderCustomers(customers) {
|
2025-12-06 02:22:01 +01:00
|
|
|
const tbody = document.getElementById('customersTableBody');
|
|
|
|
|
|
|
|
|
|
if (!customers || customers.length === 0) {
|
2025-12-16 15:36:11 +01:00
|
|
|
tbody.innerHTML = `
|
|
|
|
|
<tr><td colspan="6" class="text-center text-muted py-5">
|
|
|
|
|
Ingen kunder fundet
|
|
|
|
|
</td></tr>
|
|
|
|
|
`;
|
2025-12-06 02:22:01 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = customers.map(customer => {
|
2025-12-16 15:36:11 +01:00
|
|
|
const initials = customer.name ? customer.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() : '??';
|
|
|
|
|
const statusBadge = customer.is_active ?
|
|
|
|
|
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
|
|
|
|
|
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
|
2026-04-01 21:34:58 +02:00
|
|
|
const safeInitials = escapeHtml(initials);
|
|
|
|
|
const safeName = escapeHtml(customer.name);
|
|
|
|
|
const safeAddress = escapeHtml(customer.address);
|
|
|
|
|
const safeContactName = escapeHtml(customer.contact_name);
|
|
|
|
|
const safeContactPhone = escapeHtml(customer.contact_phone);
|
|
|
|
|
const safeCvr = escapeHtml(customer.cvr_number);
|
|
|
|
|
const safeEmail = escapeHtml(customer.email);
|
2025-12-06 02:22:01 +01:00
|
|
|
|
|
|
|
|
return `
|
2025-12-16 15:36:11 +01:00
|
|
|
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
|
2025-12-06 02:22:01 +01:00
|
|
|
<td>
|
|
|
|
|
<div class="d-flex align-items-center">
|
2025-12-16 15:36:11 +01:00
|
|
|
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
|
|
|
|
|
style="width: 40px; height: 40px; color: var(--accent);">
|
2026-04-01 21:34:58 +02:00
|
|
|
${safeInitials}
|
2025-12-16 15:36:11 +01:00
|
|
|
</div>
|
2025-12-06 02:22:01 +01:00
|
|
|
<div>
|
2026-04-01 21:34:58 +02:00
|
|
|
<div class="fw-bold">${safeName}</div>
|
|
|
|
|
<div class="small text-muted">${safeAddress}</div>
|
2025-12-06 02:22:01 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
2026-04-01 21:34:58 +02:00
|
|
|
<div class="fw-medium">${safeContactName}</div>
|
|
|
|
|
<div class="small text-muted">${safeContactPhone}</div>
|
2025-12-06 02:22:01 +01:00
|
|
|
</td>
|
2026-04-01 21:34:58 +02:00
|
|
|
<td class="text-muted">${safeCvr}</td>
|
2025-12-06 02:22:01 +01:00
|
|
|
<td>${statusBadge}</td>
|
2026-04-01 21:34:58 +02:00
|
|
|
<td class="text-muted">${safeEmail}</td>
|
2025-12-06 02:22:01 +01:00
|
|
|
<td class="text-end">
|
2025-12-16 15:36:11 +01:00
|
|
|
<button class="btn btn-sm btn-outline-primary"
|
|
|
|
|
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
|
|
|
|
|
title="Se detaljer">
|
|
|
|
|
<i class="bi bi-arrow-right"></i>
|
|
|
|
|
</button>
|
2025-12-06 02:22:01 +01:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
function renderPagination() {
|
|
|
|
|
const totalPages = Math.ceil(totalCustomers / pageSize);
|
|
|
|
|
const pagination = document.getElementById('pagination');
|
2025-12-06 02:22:01 +01:00
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
if (totalPages <= 1) {
|
|
|
|
|
pagination.innerHTML = '';
|
|
|
|
|
return;
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
let pages = [];
|
2025-12-06 02:22:01 +01:00
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
// Previous button
|
|
|
|
|
pages.push(`
|
|
|
|
|
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
|
|
|
|
<a class="page-link" href="#" onclick="loadCustomers(${currentPage - 1}); return false;">
|
|
|
|
|
<i class="bi bi-chevron-left"></i>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
`);
|
2025-12-06 02:22:01 +01:00
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
// Page numbers (show max 7 pages)
|
|
|
|
|
let startPage = Math.max(1, currentPage - 3);
|
|
|
|
|
let endPage = Math.min(totalPages, startPage + 6);
|
2025-12-06 02:22:01 +01:00
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
if (endPage - startPage < 6) {
|
|
|
|
|
startPage = Math.max(1, endPage - 6);
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
if (startPage > 1) {
|
|
|
|
|
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(1); return false;">1</a></li>`);
|
|
|
|
|
if (startPage > 2) {
|
|
|
|
|
pages.push(`<li class="page-item disabled"><span class="page-link">...</span></li>`);
|
|
|
|
|
}
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
|
|
|
pages.push(`
|
|
|
|
|
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
|
|
|
|
<a class="page-link" href="#" onclick="loadCustomers(${i}); return false;">${i}</a>
|
|
|
|
|
</li>
|
|
|
|
|
`);
|
|
|
|
|
}
|
2025-12-06 02:22:01 +01:00
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
if (endPage < totalPages) {
|
|
|
|
|
if (endPage < totalPages - 1) {
|
|
|
|
|
pages.push(`<li class="page-item disabled"><span class="page-link">...</span></li>`);
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
2025-12-16 15:36:11 +01:00
|
|
|
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(${totalPages}); return false;">${totalPages}</a></li>`);
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
2025-12-16 15:36:11 +01:00
|
|
|
|
|
|
|
|
// Next button
|
|
|
|
|
pages.push(`
|
|
|
|
|
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
|
|
|
|
<a class="page-link" href="#" onclick="loadCustomers(${currentPage + 1}); return false;">
|
|
|
|
|
<i class="bi bi-chevron-right"></i>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
pagination.innerHTML = pages.join('');
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
function updateCount() {
|
2026-04-01 21:34:58 +02:00
|
|
|
if (totalCustomers === 0) {
|
|
|
|
|
document.getElementById('customerCount').textContent = 'Ingen kunder fundet';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
const start = (currentPage - 1) * pageSize + 1;
|
|
|
|
|
const end = Math.min(currentPage * pageSize, totalCustomers);
|
|
|
|
|
document.getElementById('customerCount').textContent =
|
|
|
|
|
`Viser ${start}-${end} af ${totalCustomers} kunder`;
|
2025-12-06 02:22:01 +01:00
|
|
|
}
|
|
|
|
|
</script>
|
2025-12-16 15:36:11 +01:00
|
|
|
{% endblock %}
|