bmc_hub/app/customers/frontend/customers.html
Christian 1fb5b4a191 Add comprehensive tests for email and drift functionalities
- Introduced tests for drift operations UI and backend to ensure proper handling of ignore rules and UI elements.
- Added tests for email identity API to validate email linking and identity retrieval without updates.
- Implemented tests for email purchase link service to verify correct handling of purchase mail links and conflict scenarios.
- Created tests for email reply automation to ensure proper routing and processing of replies based on classification.
- Developed tests for email sender creation to validate suggestions and handling of existing contacts and companies.
- Added tests for email sender identity to ensure correct matching of contacts and domains.
- Implemented tests for work desk functionalities to validate task management, meeting registrations, and timer functionalities.
2026-11-03 01:16:12 +01:00

840 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}Kunder - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.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;
}
.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);
}
.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%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5 customers-toolbar">
<div>
<h2 class="fw-bold mb-1">Kunder</h2>
<p class="text-muted mb-0">Administrer dine kunder</p>
</div>
<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>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" onclick="openDuplicateCustomersModal()">
<i class="bi bi-intersect me-2"></i>Find dubletter
</button>
<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>
</div>
</div>
<div class="mb-4 d-flex gap-2">
<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>
</div>
<div class="card p-4">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Virksomhed</th>
<th>Kontakt</th>
<th>CVR</th>
<th>Status</th>
<th>E-mail</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="customersTableBody">
<tr>
<td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<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>
</div>
</div>
<div class="modal fade" id="duplicateCustomersModal" tabindex="-1" aria-labelledby="duplicateCustomersModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div><h5 class="modal-title" id="duplicateCustomersModalLabel">Mulige kundedubletter</h5><small class="text-muted">Vælg hovedkunde og kunder, der skal samles. Gennemse flytningen før du bekræfter.</small></div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body" id="duplicateCustomersContent"><div class="text-center py-5"><div class="spinner-border text-primary"></div></div></div>
</div>
</div>
</div>
<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>
<script>
let currentPage = 1;
const pageSize = 50;
let totalCustomers = 0;
let searchTerm = '';
let searchTimeout = null;
let currentRequestController = null;
let lastLoadedQueryKey = '';
let createCustomerModal = null;
let activeFilter = 'all';
// Load customers on page load
document.addEventListener('DOMContentLoaded', () => {
loadCustomers();
createCustomerModal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
// Setup search with debounce
const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClearBtn');
const triggerSearch = () => {
const nextSearchTerm = searchInput.value.trim();
if (nextSearchTerm === searchTerm) {
toggleClearButton(nextSearchTerm);
return;
}
searchTerm = nextSearchTerm;
toggleClearButton(searchTerm);
loadCustomers(1);
};
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
toggleClearButton(e.target.value.trim());
searchTimeout = setTimeout(() => {
triggerSearch();
}, 300);
});
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();
});
});
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);
});
}
async function loadCustomers(page = 1) {
currentPage = page;
const offset = (page - 1) * pageSize;
if (currentRequestController) {
currentRequestController.abort();
}
currentRequestController = new AbortController();
try {
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
if (searchTerm) {
url += `&search=${encodeURIComponent(searchTerm)}`;
}
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}`);
}
const data = await response.json();
lastLoadedQueryKey = queryKey;
totalCustomers = data.total;
renderCustomers(data.customers);
renderPagination();
updateCount();
} catch (error) {
if (error.name === 'AbortError') {
return;
}
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>
`;
} finally {
currentRequestController = null;
}
}
function duplicateEscape(value) {
return escapeHtml(value === null || value === undefined || value === '' ? '–' : value);
}
function duplicateRelationSummary(counts) {
const labels = { contacts: 'kontakter', sager: 'sager', lokationer: 'lokationer', hardware: 'hardware', abonnementer: 'abonnementer' };
return Object.entries(labels).map(([key, label]) => `${counts?.[key] || 0} ${label}`).join(' · ');
}
async function openDuplicateCustomersModal() {
customerMergePreviews.clear();
const modalElement = document.getElementById('duplicateCustomersModal');
const content = document.getElementById('duplicateCustomersContent');
bootstrap.Modal.getOrCreateInstance(modalElement).show();
content.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div><div class="text-muted mt-2">Finder dubletter…</div></div>';
try {
const response = await fetch('/api/v1/customers/duplicates', { credentials: 'include' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente dubletter');
const groups = Array.isArray(data.groups) ? data.groups : [];
if (!groups.length) {
content.innerHTML = '<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-2"></i>Ingen aktive kunder med samme navn blev fundet.</div>';
return;
}
const status = {
same_economic: ['success', 'Samme e-conomic-relation'],
conflicting_economic: ['danger', 'Forskellige e-conomic-relationer – kræver kontrol'],
missing_economic: ['warning', 'Mangler e-conomic-relation']
};
content.innerHTML = `<p class="text-muted small">${groups.length} grupper fundet. Vælg én hovedkunde og markér de kunder, der skal samles med den. Hovedkundens oplysninger bevares; tomme felter udfyldes, når værdien er entydig. Kildekunder deaktiveres. Eksterne systemer ændres ikke.</p>` + groups.map((group, groupIndex) => {
const [colour, label] = status[group.economic_status] || status.missing_economic;
const rows = group.candidates.map((customer, index) => `
<tr class="${index === 0 ? 'table-light' : ''}">
<td><input class="form-check-input" type="radio" name="merge-target-${groupIndex}" value="${Number(customer.id)}" ${index === 0 ? 'checked' : ''} aria-label="Behold som hovedkunde: ${duplicateEscape(customer.name)}" onchange="resetCustomerMerge(${groupIndex})"></td>
<td><input class="form-check-input merge-source" type="checkbox" value="${Number(customer.id)}" checked aria-label="Medtag kunde: ${duplicateEscape(customer.name)}" onchange="resetCustomerMerge(${groupIndex})"></td>
<td><a href="/customers/${customer.id}" class="fw-semibold">${duplicateEscape(customer.name)}</a>${index === 0 ? '<span class="badge text-bg-primary ms-2">Foreslået hovedkunde</span>' : ''}</td>
<td>${duplicateEscape(customer.economic_customer_number)}</td>
<td>${duplicateEscape(customer.cvr_number)}</td>
<td class="small text-muted">${duplicateRelationSummary(customer.relation_counts)}</td>
</tr>`).join('');
return `<section id="customer-merge-group-${groupIndex}" class="border rounded-3 mb-3 overflow-hidden">
<div class="d-flex justify-content-between gap-3 align-items-center px-3 py-2 bg-light"><strong>${duplicateEscape(group.candidates?.[0]?.name || group.key)}</strong><span class="badge text-bg-${colour}">${label}</span></div>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Hovedkunde</th><th>Medtag</th><th>Kunde</th><th>e-conomic</th><th>CVR</th><th>Data knyttet til posten</th></tr></thead><tbody>${rows}</tbody></table></div>
<div class="p-3 border-top"><button class="btn btn-sm btn-primary" type="button" onclick="previewCustomerMerge(${groupIndex}, this)">Vis sammenlægning</button><div class="merge-preview mt-2" aria-live="polite"></div></div>
</section>`;
}).join('');
} catch (error) {
content.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)}</div>`;
}
}
const customerMergePreviews = new Map();
function resetCustomerMerge(index) {
customerMergePreviews.delete(index);
document.querySelector(`#customer-merge-group-${index} .merge-preview`).replaceChildren();
}
function customerMergeSelection(index) {
const section = document.getElementById(`customer-merge-group-${index}`);
const target_id = Number(section.querySelector('input[type=radio]:checked').value);
const source_ids = Array.from(section.querySelectorAll('.merge-source:checked'), input => Number(input.value)).filter(id => id !== target_id);
if (!source_ids.length) throw new Error('Markér mindst én anden kunde, der skal samles med hovedkunden.');
return { target_id, source_ids };
}
async function customerMergeRequest(path, body) {
const response = await fetch(`/api/v1/customers/merge${path}`, {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kunne ikke samle kunderne. Kontrollér dit valg.');
return data;
}
function customerMergeLabel(table) {
const labels = {contact_companies: 'Kontakttilknytninger', sag_sager: 'Sager', sag_kunder: 'Ekstra sagstilknytninger',
ordre_drafts: 'Ordrer og fakturakladder', supplier_invoices: 'Leverandørfakturaer', supplier_invoice_lines: 'Fakturalinjer',
entity_tags: 'Tags', alert_notes: 'Vigtige kundenoter', customer_notes: 'Kundenoter',
hardware_assets: 'Hardware', hardware_ownership_history: 'Hardwarehistorik',
tmodule_customers: 'Koblinger til tidsmodulet', tmodule_orders: 'Ordrekoblinger',
sag_subscriptions: 'Abonnementer', locations_locations: 'Lokationer',
email_messages: 'E-mails', conversations: 'Samtaler', tticket_tickets: 'Tickets',
quick_time_items: 'Tidsregistrering og planlægning', customers: 'Kundehenvisninger',
bmc_workflows: 'Kundeworkflows', customer_relationships: 'Kundeforbindelser',
bmc_office_subscriptions: 'Office-abonnementer', customer_fixed_price_agreements: 'Fastprisaftaler',
customer_vendor_links: 'Leverandørtilknytninger', email_domain_customer_mappings: 'E-maildomæner',
email_templates: 'E-mailskabeloner', hardware: 'Hardwaretilknytninger',
customer_house_organizations: 'Virksomheder i huset', contact_location_users: 'Brugere i huset',
economic_price_rules: 'Prisregler', pipeline_opportunities: 'Salgsmuligheder',
task_templates: 'Opgaveskabeloner', knowledge_articles: 'Vidensartikler',
locations_wall_outlets: 'Netværksudtag', links: 'Links og adgange',
link_access_log: 'Adgangshistorik', link_runbooks: 'Driftsvejledninger'};
if (labels[table]) return labels[table];
const families = {also: 'ALSO-tilknytninger', anydesk: 'AnyDesk', bmc_workflow: 'Workflowtilknytninger',
drift: 'Driftstilknytninger', internet_connections: 'Internetforbindelser', invoice_error_finder: 'Fakturakontrol',
migration_center: 'Importerede kundetilknytninger', nextcloud: 'Nextcloud', simply: 'Simply-abonnementer',
vtiger: 'vTiger-tilknytninger', webshop: 'Webshop', tticket: 'Tickets og klippekort',
workflow_tag: 'Taghistorik', fedex: 'FedEx-forsendelser', shipmondo: 'Shipmondo-forsendelser'};
return Object.entries(families).find(([prefix]) => table.startsWith(prefix))?.[1] || 'Øvrige kundetilknytninger';
}
function customerMergeField(field) {
return ({email:'E-mail', phone:'Telefon', address:'Adresse', email_domain:'E-maildomæne', city:'By',
postal_code:'Postnummer', country:'Land', website:'Website', invoice_email:'Faktura-e-mail',
mobile_phone:'Mobil', wiki_slug:'Wiki', economic_customer_number:'e-conomic-kundenummer',
vtiger_id:'vTiger-id', vtiger_account_no:'vTiger-kundenummer', cvr_number:'CVR', name:'Navn',
payment_terms_number:'Betalingsbetingelser', payment_terms_type:'Betalingstype', vat_zone_number:'Momszone',
customer_group_number:'Kundegruppe', barred:'Spærring', currency_code:'Valuta', ean:'EAN',
public_entry_number:'Offentligt kundenummer', subscriptions_locked:'Abonnementslås',
standard_margin_percent:'Avance', special_freight_price:'Fragtpris', supplier_service_enrolled:'Leverandørservice',
invoice_fee_amount:'Fakturagebyr', invoice_fee_exempt:'Gebyrfritagelse', standard_hourly_rate:'Timepris',
is_bmc_customer:'Kundestatus', show_in_customer_list:'Visning i kundelisten',is_active:'Aktiv status'})[field] || 'Kundeoplysning';
}
async function previewCustomerMerge(index, button) {
const area = document.querySelector(`#customer-merge-group-${index} .merge-preview`);
resetCustomerMerge(index);
button.disabled = true;
const section = document.getElementById(`customer-merge-group-${index}`);
section.querySelectorAll('input').forEach(input => input.disabled = true);
area.textContent = 'Kontrollerer alle relationer og mulige konflikter…';
try {
const selection = customerMergeSelection(index);
const data = await customerMergeRequest('/preview', selection);
customerMergePreviews.set(index, {...selection, preview_token: data.preview_token});
const main = data.customers.find(customer => customer.id === data.target_id);
const total = data.relations.reduce((sum, item) => sum + item.count, 0);
area.innerHTML = `<div class="alert alert-info mb-2"><strong>${data.source_ids.length} kunder samles under ${duplicateEscape(main.name)} (#${data.target_id})</strong><div>${total} relationer berøres. Sager, kontakter og lokale økonomirelationer følger med. Fakturaer i e-conomic forbliver på det eksisterende kundenummer.</div></div>` +
`<ul class="small mb-2">${data.relations.map(item => `<li>${duplicateEscape(customerMergeLabel(item.table))}: ${item.count}</li>`).join('')}</ul>` +
(data.filled_fields.length ? `<div class="small">Tomme felter udfyldes: ${duplicateEscape(data.filled_fields.map(customerMergeField).join(', '))}.</div>` : '') +
(data.kept_main_fields.length ? `<div class="small text-warning">Forskellige oplysninger: ${duplicateEscape(data.kept_main_fields.map(customerMergeField).join(', '))}. Hovedkundens værdier bevares; originalerne gemmes i historikken.</div>` : '') +
`<div class="small text-muted my-2">Kildekunder deaktiveres og knyttes til hovedkunden. Flytningen og de oprindelige kundeoplysninger gemmes i historikken.</div><button type="button" class="btn btn-sm btn-success" onclick="confirmCustomerMerge(${index}, this)">Bekræft sammenlægning af ${data.source_ids.length + 1} kunder</button>`;
} catch (error) {
area.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)}</div>`;
} finally {
button.disabled = false;
section.querySelectorAll('input').forEach(input => input.disabled = false);
}
}
async function confirmCustomerMerge(index, button) {
const preview = customerMergePreviews.get(index);
if (!preview) return;
button.disabled = true;
const area = document.querySelector(`#customer-merge-group-${index} .merge-preview`);
try {
const result = await customerMergeRequest('', preview);
customerMergePreviews.delete(index);
const section = document.getElementById(`customer-merge-group-${index}`);
section.querySelectorAll('input, button').forEach(input => input.disabled = true);
area.innerHTML = `<div class="alert alert-success mb-0">Kunderne er samlet. <a href="/customers/${result.target_id}">Åbn hovedkunden</a>. Historik #${result.history_id}.</div>`;
loadCustomers();
} catch (error) {
customerMergePreviews.delete(index);
area.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)} Vis sammenlægningen igen før et nyt forsøg.</div>`;
}
}
function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
}
function escapeHtml(value) {
if (value === null || value === undefined) {
return '-';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderCustomers(customers) {
const tbody = document.getElementById('customersTableBody');
if (!customers || customers.length === 0) {
tbody.innerHTML = `
<tr><td colspan="6" class="text-center text-muted py-5">
Ingen kunder fundet
</td></tr>
`;
return;
}
tbody.innerHTML = customers.map(customer => {
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>';
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);
return `
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
<td>
<div class="d-flex align-items-center">
<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);">
${safeInitials}
</div>
<div>
<div class="fw-bold">${safeName}</div>
<div class="small text-muted">${safeAddress}</div>
</div>
</div>
</td>
<td>
<div class="fw-medium">${safeContactName}</div>
<div class="small text-muted">${safeContactPhone}</div>
</td>
<td class="text-muted">${safeCvr}</td>
<td>${statusBadge}</td>
<td class="text-muted">${safeEmail}</td>
<td class="text-end">
<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>
</td>
</tr>
`;
}).join('');
}
function renderPagination() {
const totalPages = Math.ceil(totalCustomers / pageSize);
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let pages = [];
// 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>
`);
// Page numbers (show max 7 pages)
let startPage = Math.max(1, currentPage - 3);
let endPage = Math.min(totalPages, startPage + 6);
if (endPage - startPage < 6) {
startPage = Math.max(1, endPage - 6);
}
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>`);
}
}
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>
`);
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages.push(`<li class="page-item disabled"><span class="page-link">...</span></li>`);
}
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(${totalPages}); return false;">${totalPages}</a></li>`);
}
// 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('');
}
function updateCount() {
if (totalCustomers === 0) {
document.getElementById('customerCount').textContent = 'Ingen kunder fundet';
return;
}
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalCustomers);
document.getElementById('customerCount').textContent =
`Viser ${start}-${end} af ${totalCustomers} kunder`;
}
</script>
{% endblock %}