bmc_hub/app/customers/frontend/customers.html

840 lines
38 KiB
HTML
Raw Normal View History

{% 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>
2026-09-25 11:33:53 +02:00
<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>
2026-09-25 11:33:53 +02:00
<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>
2026-09-25 11:33:53 +02:00
<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;
}
}
2026-09-25 11:33:53 +02:00
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();
2026-09-25 11:33:53 +02:00
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) => {
2026-09-25 11:33:53 +02:00
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>
2026-09-25 11:33:53 +02:00
<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">
2026-09-25 11:33:53 +02:00
<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>
2026-09-25 11:33:53 +02:00
</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 %}