@@ -124,6 +187,20 @@ const linkSagState = {
searchToken: 0,
modal: null
};
+const linkContactState = {
+ callId: null,
+ selectedContactId: null,
+ selectedLabel: '',
+ searchTimer: null,
+ companySearchTimer: null,
+ searchToken: 0,
+ companySearchToken: 0,
+ modal: null,
+ number: '',
+ mode: 'contact',
+ selectedCompanyId: null,
+ selectedCompanyLabel: ''
+};
async function ensureCurrentUserId() {
if (telefoniCurrentUserId !== null) return telefoniCurrentUserId;
@@ -147,6 +224,385 @@ function getLinkSagModalInstance() {
return linkSagState.modal;
}
+function getLinkContactModalInstance() {
+ if (!linkContactState.modal) {
+ const el = document.getElementById('linkContactModal');
+ if (!el || !window.bootstrap) return null;
+ linkContactState.modal = new bootstrap.Modal(el);
+ }
+ return linkContactState.modal;
+}
+
+function setLinkContactSelected(contactId, label) {
+ const selected = document.getElementById('linkContactSelected');
+ const confirmBtn = document.getElementById('linkContactConfirm');
+ const numericContactId = Number(contactId);
+ if (!Number.isInteger(numericContactId) || numericContactId <= 0) {
+ linkContactState.selectedContactId = null;
+ linkContactState.selectedLabel = '';
+ if (selected) {
+ selected.className = 'alert alert-secondary py-2 mb-3';
+ selected.textContent = 'Ingen kontakt valgt';
+ }
+ if (confirmBtn) confirmBtn.disabled = true;
+ return;
+ }
+
+ linkContactState.selectedContactId = numericContactId;
+ linkContactState.selectedLabel = String(label || `Kontakt #${numericContactId}`);
+ if (selected) {
+ selected.className = 'alert alert-success py-2 mb-3';
+ selected.textContent = `Valgt: ${linkContactState.selectedLabel} (ID: ${numericContactId})`;
+ }
+ if (confirmBtn) confirmBtn.disabled = false;
+}
+
+function setLinkCompanySelected(companyId, label) {
+ const selected = document.getElementById('linkCompanySelected');
+ const numericCompanyId = Number(companyId);
+ if (!Number.isInteger(numericCompanyId) || numericCompanyId <= 0) {
+ linkContactState.selectedCompanyId = null;
+ linkContactState.selectedCompanyLabel = '';
+ if (selected) {
+ selected.className = 'alert alert-secondary py-2 mb-3';
+ selected.textContent = 'Intet firma valgt';
+ }
+ return;
+ }
+
+ linkContactState.selectedCompanyId = numericCompanyId;
+ linkContactState.selectedCompanyLabel = String(label || `Firma #${numericCompanyId}`);
+ if (selected) {
+ selected.className = 'alert alert-success py-2 mb-3';
+ selected.textContent = `Valgt: ${linkContactState.selectedCompanyLabel} (ID: ${numericCompanyId})`;
+ }
+}
+
+function renderLinkContactResults(results) {
+ const container = document.getElementById('linkContactResults');
+ if (!container) return;
+
+ if (!results || results.length === 0) {
+ container.innerHTML = '
Ingen kontakter fundet
';
+ return;
+ }
+
+ container.innerHTML = (results || []).map((item) => {
+ const cid = Number(item.id);
+ const name = `${item.first_name || ''} ${item.last_name || ''}`.trim() || `Kontakt #${cid}`;
+ const email = String(item.email || '-');
+ const phone = String(item.mobile || item.phone || '-');
+ return `
+
+ `;
+ }).join('');
+
+ container.querySelectorAll('[data-contact-id]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const cid = Number(btn.getAttribute('data-contact-id'));
+ const label = btn.getAttribute('data-contact-label') || `Kontakt #${cid}`;
+ setLinkContactSelected(cid, label);
+ });
+ });
+}
+
+async function searchContacts(query) {
+ const token = ++linkContactState.searchToken;
+ const container = document.getElementById('linkContactResults');
+ if (container) {
+ container.innerHTML = '
Søger...
';
+ }
+
+ try {
+ const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
+ const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' });
+ if (token !== linkContactState.searchToken) return;
+ if (!res.ok) {
+ if (container) container.innerHTML = '
Kunne ikke søge kontakter
';
+ return;
+ }
+ const data = await res.json();
+ const results = Array.isArray(data?.contacts) ? data.contacts : [];
+ renderLinkContactResults(results);
+ } catch (e) {
+ if (token !== linkContactState.searchToken) return;
+ if (container) container.innerHTML = '
Fejl under søgning
';
+ }
+}
+
+async function patchCallContact(callId, contactId) {
+ const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ kontakt_id: contactId })
+ });
+ if (!res.ok) {
+ const t = await res.text();
+ throw new Error(t || `HTTP ${res.status}`);
+ }
+}
+
+function renderCompanyResults(results) {
+ const container = document.getElementById('linkCompanyResults');
+ if (!container) return;
+
+ if (!results || results.length === 0) {
+ container.innerHTML = '
Ingen firmaer fundet
';
+ return;
+ }
+
+ container.innerHTML = (results || []).map((item) => {
+ const cid = Number(item.id);
+ const name = String(item.name || `Firma #${cid}`);
+ const cvr = String(item.cvr_nummer || item.cvr_number || '-');
+ return `
+
+ `;
+ }).join('');
+
+ container.querySelectorAll('[data-company-id]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const cid = Number(btn.getAttribute('data-company-id'));
+ const label = btn.getAttribute('data-company-label') || `Firma #${cid}`;
+ setLinkCompanySelected(cid, label);
+ });
+ });
+}
+
+async function searchCompanies(query) {
+ const token = ++linkContactState.companySearchToken;
+ const container = document.getElementById('linkCompanyResults');
+ if (container) {
+ container.innerHTML = '
Søger...
';
+ }
+
+ try {
+ const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query || '')}`, { credentials: 'include' });
+ if (token !== linkContactState.companySearchToken) return;
+ if (!res.ok) {
+ if (container) container.innerHTML = '
Kunne ikke søge firmaer
';
+ return;
+ }
+ const data = await res.json();
+ renderCompanyResults(Array.isArray(data) ? data : []);
+ } catch (e) {
+ if (token !== linkContactState.companySearchToken) return;
+ if (container) container.innerHTML = '
Fejl under søgning
';
+ }
+}
+
+function initLinkContactModalEvents() {
+ const searchInput = document.getElementById('linkContactSearch');
+ const companySearchInput = document.getElementById('linkCompanySearch');
+ const confirmBtn = document.getElementById('linkContactConfirm');
+ const createBtn = document.getElementById('createContactConfirm');
+ const createCompanyCaseBtn = document.getElementById('createCompanyCaseConfirm');
+ const modalEl = document.getElementById('linkContactModal');
+ if (!searchInput || !companySearchInput || !confirmBtn || !createBtn || !createCompanyCaseBtn) return;
+
+ searchInput.addEventListener('input', () => {
+ if (linkContactState.searchTimer) clearTimeout(linkContactState.searchTimer);
+ const q = String(searchInput.value || '').trim();
+ linkContactState.searchTimer = setTimeout(() => {
+ if (!q) {
+ renderLinkContactResults([]);
+ return;
+ }
+ searchContacts(q);
+ }, 250);
+ });
+
+ companySearchInput.addEventListener('input', () => {
+ if (linkContactState.companySearchTimer) clearTimeout(linkContactState.companySearchTimer);
+ const q = String(companySearchInput.value || '').trim();
+ linkContactState.companySearchTimer = setTimeout(() => {
+ if (q.length < 2) {
+ renderCompanyResults([]);
+ return;
+ }
+ searchCompanies(q);
+ }, 250);
+ });
+
+ confirmBtn.addEventListener('click', async () => {
+ if (!linkContactState.callId || !linkContactState.selectedContactId) return;
+ confirmBtn.disabled = true;
+ try {
+ await patchCallContact(linkContactState.callId, linkContactState.selectedContactId);
+ const modal = getLinkContactModalInstance();
+ if (modal) modal.hide();
+ await loadCalls();
+ } catch (error) {
+ alert('Kunne ikke knytte kontakt: ' + (error?.message || 'ukendt fejl'));
+ } finally {
+ confirmBtn.disabled = false;
+ }
+ });
+
+ createBtn.addEventListener('click', async () => {
+ const firstName = String(document.getElementById('newContactFirstName')?.value || '').trim();
+ const lastName = String(document.getElementById('newContactLastName')?.value || '').trim();
+ const email = String(document.getElementById('newContactEmail')?.value || '').trim();
+ const title = String(document.getElementById('newContactTitle')?.value || '').trim();
+ const phoneInput = String(document.getElementById('newContactPhone')?.value || '').trim() || linkContactState.number;
+
+ if (!firstName) {
+ alert('Fornavn er påkrævet for at oprette kontakt');
+ return;
+ }
+ if (!linkContactState.callId) {
+ alert('Opkald mangler');
+ return;
+ }
+
+ createBtn.disabled = true;
+ createBtn.textContent = 'Opretter...';
+ try {
+ const res = await fetch('/api/v1/contacts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ first_name: firstName,
+ last_name: lastName,
+ email: email || null,
+ phone: phoneInput || null,
+ title: title || null,
+ })
+ });
+
+ if (!res.ok) {
+ const t = await res.text();
+ throw new Error(t || `HTTP ${res.status}`);
+ }
+
+ const created = await res.json();
+ const contactId = Number(created?.id || 0);
+ if (!Number.isInteger(contactId) || contactId <= 0) {
+ throw new Error('Kontakt blev oprettet men ID mangler');
+ }
+
+ await patchCallContact(linkContactState.callId, contactId);
+ const modal = getLinkContactModalInstance();
+ if (modal) modal.hide();
+ await loadCalls();
+ } catch (error) {
+ alert('Kunne ikke oprette kontakt: ' + (error?.message || 'ukendt fejl'));
+ } finally {
+ createBtn.disabled = false;
+ createBtn.textContent = 'Opret og knyt';
+ }
+ });
+
+ createCompanyCaseBtn.addEventListener('click', async () => {
+ if (!linkContactState.callId) {
+ alert('Opkald mangler');
+ return;
+ }
+ if (!linkContactState.selectedCompanyId) {
+ alert('Vælg et firma først');
+ return;
+ }
+
+ const numberForTitle = linkContactState.number || 'ukendt nummer';
+ const qs = new URLSearchParams();
+ qs.set('customer_id', String(linkContactState.selectedCompanyId));
+ qs.set('telefoni_opkald_id', String(linkContactState.callId));
+ qs.set('title', `Telefonsamtale – ${numberForTitle}`);
+ qs.set('description', `Opkald fra firmaets hovednummer: ${numberForTitle}`);
+ window.location.href = `/sag/new?${qs.toString()}`;
+ });
+
+ if (modalEl) {
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ if (linkContactState.searchTimer) clearTimeout(linkContactState.searchTimer);
+ if (linkContactState.companySearchTimer) clearTimeout(linkContactState.companySearchTimer);
+ linkContactState.searchTimer = null;
+ linkContactState.companySearchTimer = null;
+ linkContactState.searchToken++;
+ linkContactState.companySearchToken++;
+ linkContactState.callId = null;
+ linkContactState.number = '';
+ linkContactState.mode = 'contact';
+ setLinkContactSelected(null, '');
+ setLinkCompanySelected(null, '');
+ searchInput.value = '';
+ companySearchInput.value = '';
+ document.getElementById('newContactFirstName').value = '';
+ document.getElementById('newContactLastName').value = '';
+ document.getElementById('newContactPhone').value = '';
+ document.getElementById('newContactEmail').value = '';
+ document.getElementById('newContactTitle').value = '';
+ const results = document.getElementById('linkContactResults');
+ const companyResults = document.getElementById('linkCompanyResults');
+ const ctx = document.getElementById('linkContactContext');
+ if (results) results.innerHTML = '';
+ if (companyResults) companyResults.innerHTML = '';
+ if (ctx) ctx.textContent = 'Opkald: -';
+ });
+ }
+}
+
+function openLinkContactModal(callId, mode = 'contact') {
+ const numericCallId = Number(callId);
+ const call = telefoniCallMap.get(numericCallId);
+ linkContactState.callId = numericCallId;
+ linkContactState.number = String(call?.display_number || call?.ekstern_nummer || '').trim();
+ linkContactState.mode = mode;
+
+ const ctx = document.getElementById('linkContactContext');
+ const searchInput = document.getElementById('linkContactSearch');
+ const companySearchInput = document.getElementById('linkCompanySearch');
+ const phoneInput = document.getElementById('newContactPhone');
+ const firstNameInput = document.getElementById('newContactFirstName');
+ const label = call
+ ? `${call.direction === 'outbound' ? 'Udgående' : 'Indgående'} · ${linkContactState.number || '-'} · ${call.started_at ? new Date(call.started_at).toLocaleString('da-DK') : '-'}`
+ : `Opkald #${numericCallId}`;
+
+ if (ctx) ctx.textContent = `Opkald: ${label}`;
+ if (searchInput) searchInput.value = linkContactState.number;
+ if (companySearchInput) companySearchInput.value = '';
+ if (phoneInput) phoneInput.value = linkContactState.number;
+ if (firstNameInput && !firstNameInput.value) {
+ firstNameInput.value = 'Ukendt';
+ }
+ setLinkContactSelected(null, '');
+ setLinkCompanySelected(null, '');
+ renderLinkContactResults([]);
+ renderCompanyResults([]);
+
+ const modal = getLinkContactModalInstance();
+ if (modal) modal.show();
+
+ setTimeout(() => {
+ if (mode === 'company') {
+ companySearchInput?.focus();
+ } else {
+ searchInput?.focus();
+ }
+ if (linkContactState.number) {
+ searchContacts(linkContactState.number);
+ }
+ }, 200);
+}
+
+async function unlinkContact(callId) {
+ if (!confirm('Fjern link til kontakt for dette opkald?')) return;
+ try {
+ await patchCallContact(callId, null);
+ await loadCalls();
+ } catch (error) {
+ alert('Kunne ikke fjerne kontakt-link: ' + (error?.message || 'ukendt fejl'));
+ }
+}
+
function setLinkSagSelected(sagId, label) {
const selected = document.getElementById('linkSagSelected');
const confirmBtn = document.getElementById('linkSagConfirm');
@@ -408,8 +864,15 @@ async function loadCalls() {
: '-';
const contactHtml = r.kontakt_id
- ? `
${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}${r.contact_company ? `
${escapeHtml(r.contact_company)}
` : ''}`
- : '
-';
+ ? `
+ ${r.contact_company ? `
${escapeHtml(r.contact_company)}
` : ''}`
+ : `
`;
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
const createQs = new URLSearchParams();
@@ -500,6 +963,7 @@ async function unlinkCase(callId) {
}
document.addEventListener('DOMContentLoaded', async () => {
+ initLinkContactModalEvents();
initLinkSagModalEvents();
const userFilter = document.getElementById('filterUser');
const fromFilter = document.getElementById('filterFrom');
diff --git a/app/services/economic_service.py b/app/services/economic_service.py
index 7dda60b..bc524c1 100644
--- a/app/services/economic_service.py
+++ b/app/services/economic_service.py
@@ -129,13 +129,14 @@ class EconomicService:
# ========== CUSTOMER MANAGEMENT ==========
- async def get_customers(self, page: int = 0, page_size: int = 1000) -> List[Dict]:
+ async def get_customers(self, page: int = 0, page_size: int = 1000, strict: bool = False) -> List[Dict]:
"""
Get customers from e-conomic
Args:
page: Page number (0-indexed)
page_size: Number of records per page
+ strict: Raise exception on non-200 responses instead of returning []
Returns:
List of customer records with customerNumber, corporateIdentificationNumber, name
@@ -155,9 +156,25 @@ class EconomicService:
else:
error = await response.text()
logger.error(f"❌ Failed to fetch customers: {response.status} - {error}")
+ if strict:
+ detail = f"e-conomic kundehentning fejlede ({response.status})"
+ try:
+ payload = json.loads(error)
+ if isinstance(payload, dict):
+ message = payload.get('message') or payload.get('developerHint')
+ code = payload.get('errorCode')
+ if message and code:
+ detail = f"e-conomic kundehentning fejlede: {message} ({code})"
+ elif message:
+ detail = f"e-conomic kundehentning fejlede: {message}"
+ except Exception:
+ pass
+ raise RuntimeError(detail)
return []
except Exception as e:
logger.error(f"❌ Error fetching customers from e-conomic: {e}")
+ if strict:
+ raise
return []
async def search_customer_by_cvr(self, cvr: str) -> Optional[Dict]:
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index fe2ccbf..5c3b1d2 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -960,9 +960,12 @@