bmc_hub/app/contacts/frontend/contacts.html
Christian 30d1be61eb feat: Add global search functionality and email results section
- Introduced a global search button and modal for enhanced user experience.
- Added a new section for displaying email results in the global search modal.
- Implemented functionality to fetch and display emails based on user queries.
- Updated the UI to include a reminders button and improved accessibility features.

fix: Update docker-compose to allow reload configuration

- Changed ENABLE_RELOAD environment variable to default to true for easier development.

chore: Update requirements for new dependencies

- Added brother_ql, pyzbar, and pypdfium2 to requirements for label printing and PDF processing.

feat: Implement Brother label printing service

- Created a new service for printing labels using Brother QL printers.
- Supports direct printing of case hardware labels with customizable layouts.

feat: Add Vaultwarden service for credential management

- Implemented a service to interact with Vaultwarden for secure credential storage and retrieval.

sql: Add migrations for email thread keys and document tokens

- Created migrations to backfill email thread keys and manage document tokens for work orders.
- Introduced new tables and updated existing structures to support token-based linking of scanned documents.

sql: Import links into the database

- Added a script to import a predefined set of links into the database with associated categories.
2026-04-01 21:34:58 +02:00

1124 lines
39 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Kontakter - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.contacts-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;
cursor: pointer;
}
.filter-btn:hover, .filter-btn.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.contacts-shell {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(2, 32, 71, 0.06);
}
.contacts-table-wrap {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 12px;
max-height: min(68vh, 780px);
overflow: auto;
}
.contacts-table {
margin-bottom: 0;
}
.contacts-shell .table > :not(caption) > * > * {
padding-top: 0.85rem;
padding-bottom: 0.85rem;
vertical-align: middle;
}
.contacts-shell .table-hover > tbody > tr:hover {
--bs-table-accent-bg: rgba(15, 76, 117, 0.05);
}
.contacts-shell .table tbody tr {
cursor: pointer;
transition: background-color 0.18s ease;
}
.contacts-shell .table tbody tr:nth-child(even) {
background: rgba(15, 76, 117, 0.015);
}
.contacts-shell .table thead th {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
border-bottom-width: 1px;
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-card);
box-shadow: 0 1px 0 rgba(15, 76, 117, 0.12);
}
.contact-name {
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.contact-subline {
font-size: 0.82rem;
color: var(--text-secondary);
margin-top: 0.1rem;
}
.contact-info-main {
font-weight: 500;
color: var(--text-primary);
}
.contact-quick-actions {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.18rem;
flex-wrap: wrap;
}
.contact-quick-actions .btn {
border-radius: 999px;
padding: 0.08rem 0.52rem;
font-size: 0.72rem;
line-height: 1.2;
}
.company-count-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid rgba(15, 76, 117, 0.2);
background: rgba(15, 76, 117, 0.06);
color: var(--accent);
border-radius: 999px;
padding: 0.2rem 0.58rem;
font-size: 0.75rem;
font-weight: 600;
}
.status-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
padding: 0.24rem 0.62rem;
border: 1px solid transparent;
}
.status-pill.active {
background: rgba(17, 153, 84, 0.12);
border-color: rgba(17, 153, 84, 0.24);
color: #0b6b3a;
}
.status-pill.inactive {
background: rgba(108, 117, 125, 0.13);
border-color: rgba(108, 117, 125, 0.24);
color: #5b6570;
}
.btn-table-action {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid rgba(15, 76, 117, 0.16);
background: var(--bg-card);
color: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-table-action:hover {
background: rgba(15, 76, 117, 0.08);
border-color: rgba(15, 76, 117, 0.28);
}
.contact-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
box-shadow: inset 0 0 0 1px rgba(15, 76, 117, 0.12);
}
.pagination-btn {
border: 1px solid rgba(0,0,0,0.1);
padding: 0.5rem 1rem;
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background: var(--accent-light);
border-color: var(--accent);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.create-contact-modal .modal-content {
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 16px;
box-shadow: 0 22px 50px rgba(2, 32, 71, 0.22);
}
.create-contact-modal .modal-header {
border-bottom: 1px solid rgba(15, 76, 117, 0.12);
background: linear-gradient(180deg, rgba(15, 76, 117, 0.06) 0%, rgba(15, 76, 117, 0.02) 100%);
}
.create-contact-modal .modal-title {
font-weight: 700;
color: var(--accent);
}
.company-picker {
border: 1px solid rgba(15, 76, 117, 0.18);
border-radius: 12px;
padding: 0.6rem;
background: rgba(15, 76, 117, 0.02);
}
.company-search-input {
border-radius: 10px;
}
.company-results {
margin-top: 0.5rem;
max-height: 180px;
overflow: auto;
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 10px;
background: var(--bg-card);
}
.company-result-item {
width: 100%;
border: 0;
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
padding: 0.5rem 0.65rem;
text-align: left;
background: transparent;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
cursor: pointer;
}
.company-result-item:last-child {
border-bottom: 0;
}
.company-result-item:hover {
background: rgba(15, 76, 117, 0.08);
}
.company-result-item.selected {
background: rgba(15, 76, 117, 0.12);
color: var(--accent);
font-weight: 600;
}
.selected-companies {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.55rem;
}
.company-chip {
border-radius: 999px;
border: 1px solid rgba(15, 76, 117, 0.25);
background: rgba(15, 76, 117, 0.1);
color: var(--accent);
font-size: 0.76rem;
padding: 0.22rem 0.55rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.company-chip button {
border: 0;
background: transparent;
color: inherit;
line-height: 1;
padding: 0;
}
@media (max-width: 992px) {
.contacts-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 contacts-toolbar">
<div>
<h2 class="fw-bold mb-1">Kontakter</h2>
<p class="text-muted mb-0">Administrer kontaktpersoner</p>
</div>
<div class="toolbar-search-slot">
<div class="search-wrap">
<input type="search" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon eller firma..." 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>
<button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button>
</div>
<div class="mb-4 d-flex gap-2 flex-wrap">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
Alle Kontakter <span id="countAll" class="ms-1"></span>
</button>
<button class="filter-btn" data-filter="active" onclick="setFilter('active')">
Aktive <span id="countActive" class="ms-1"></span>
</button>
<button class="filter-btn" data-filter="inactive" onclick="setFilter('inactive')">
Inaktive <span id="countInactive" class="ms-1"></span>
</button>
</div>
<div class="card p-4 contacts-shell">
<div class="table-responsive contacts-table-wrap">
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Kontakt Info</th>
<th>Titel</th>
<th>Firmaer</th>
<th>Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="contactsTableBody">
<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>
<!-- Pagination -->
<div class="d-flex justify-content-between align-items-center mt-4">
<div class="text-muted small">
Viser <span id="showingStart">0</span>-<span id="showingEnd">0</span> af <span id="totalCount">0</span> kontakter
</div>
<div class="d-flex gap-2">
<button class="pagination-btn" id="prevBtn" onclick="previousPage()">
<i class="bi bi-chevron-left"></i> Forrige
</button>
<button class="pagination-btn" id="nextBtn" onclick="nextPage()">
Næste <i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
<!-- Create Contact Modal -->
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Ny Kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createContactForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Fornavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="firstNameInput" required>
</div>
<div class="col-md-6">
<label class="form-label">Efternavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="lastNameInput" required>
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="emailInput">
</div>
<div class="col-md-6">
<label class="form-label">Telefon</label>
<input type="text" class="form-control" id="phoneInput">
</div>
<div class="col-md-6">
<label class="form-label">Mobil</label>
<input type="text" class="form-control" id="mobileInput">
</div>
<div class="col-md-6">
<label class="form-label">Titel</label>
<input type="text" class="form-control" id="titleInput" placeholder="CEO, CTO, Manager...">
</div>
<div class="col-md-6">
<label class="form-label">Afdeling</label>
<input type="text" class="form-control" id="departmentInput">
</div>
<div class="col-md-6">
<label class="form-label">Rolle</label>
<input type="text" class="form-control" id="roleInput" placeholder="Primær kontakt, Fakturering...">
</div>
<div class="col-12">
<label class="form-label">Firmaer</label>
<div class="company-picker">
<input
type="search"
id="companySearchInput"
class="form-control company-search-input"
placeholder="Søg firma..."
autocomplete="off"
spellcheck="false"
>
<div class="company-results" id="companyResults"></div>
<div class="selected-companies" id="selectedCompanies"></div>
</div>
<div class="form-text">Vælg et eller flere firmaer ved at søge og klikke.</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isPrimaryInput">
<label class="form-check-label" for="isPrimaryInput">
Primær kontakt (for første valgte firma)
</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Noter</label>
<textarea class="form-control" id="notesInput" rows="3"></textarea>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isActiveInput" checked>
<label class="form-check-label" for="isActiveInput">
Aktiv kontakt
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createContact()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button>
</div>
</div>
</div>
</div>
<!-- Edit Contact Modal -->
<div class="modal fade" id="editContactModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger Kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editContactForm">
<input type="hidden" id="editContactId">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Fornavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="editFirstNameInput" required>
</div>
<div class="col-md-6">
<label class="form-label">Efternavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="editLastNameInput" required>
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="editEmailInput">
</div>
<div class="col-md-6">
<label class="form-label">Telefon</label>
<input type="text" class="form-control" id="editPhoneInput">
</div>
<div class="col-md-6">
<label class="form-label">Mobil</label>
<input type="text" class="form-control" id="editMobileInput">
</div>
<div class="col-md-6">
<label class="form-label">Titel</label>
<input type="text" class="form-control" id="editTitleInput" placeholder="CEO, CTO, Manager...">
</div>
<div class="col-md-6">
<label class="form-label">Afdeling</label>
<input type="text" class="form-control" id="editDepartmentInput">
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editIsActiveInput">
<label class="form-check-label" for="editIsActiveInput">
Aktiv kontakt
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveEditContact()">
<i class="bi bi-check-lg me-2"></i>Gem Ændringer
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let currentPage = 0;
let pageSize = 20;
let currentFilter = 'all';
let searchQuery = '';
let totalContacts = 0;
let searchTimeout = null;
let currentRequestController = null;
let lastLoadedQueryKey = '';
let availableCompanies = [];
let selectedCompanyIds = new Set();
// Load contacts on page load
document.addEventListener('DOMContentLoaded', () => {
loadContacts();
loadCompaniesForSelect();
const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClearBtn');
const triggerSearch = () => {
const nextSearch = searchInput.value.trim();
if (nextSearch === searchQuery) {
toggleClearButton(nextSearch);
return;
}
searchQuery = nextSearch;
currentPage = 0;
toggleClearButton(searchQuery);
loadContacts();
};
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
toggleClearButton(e.target.value.trim());
searchTimeout = setTimeout(() => {
triggerSearch();
}, 300);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(searchTimeout);
triggerSearch();
return;
}
if (e.key === 'Escape') {
if (!searchInput.value) {
toggleClearButton('');
return;
}
searchInput.value = '';
clearTimeout(searchTimeout);
triggerSearch();
}
});
clearBtn.addEventListener('click', () => {
if (!searchInput.value) {
toggleClearButton('');
return;
}
searchInput.value = '';
clearTimeout(searchTimeout);
triggerSearch();
searchInput.focus();
});
document.getElementById('companySearchInput')?.addEventListener('input', (e) => {
renderCompanyResults(e.target.value || '');
});
});
function setFilter(filter) {
currentFilter = filter;
currentPage = 0;
// Update active button
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
loadContacts();
}
async function loadContacts() {
const tbody = document.getElementById('contactsTableBody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
if (currentRequestController) {
currentRequestController.abort();
}
currentRequestController = new AbortController();
try {
// Build query parameters
let params = new URLSearchParams({
limit: pageSize,
offset: currentPage * pageSize
});
if (searchQuery) {
params.append('search', searchQuery);
}
if (currentFilter === 'active') {
params.append('is_active', 'true');
} else if (currentFilter === 'inactive') {
params.append('is_active', 'false');
}
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
if (queryKey === lastLoadedQueryKey) {
return;
}
lastLoadedQueryKey = queryKey;
const response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal });
const data = await response.json();
totalContacts = data.total;
displayContacts(data.contacts);
updatePagination(data.total);
} catch (error) {
if (error.name === 'AbortError') {
return;
}
console.error('Failed to load contacts:', error);
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</td></tr>';
} finally {
currentRequestController = null;
}
}
function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
}
function displayContacts(contacts) {
const tbody = document.getElementById('contactsTableBody');
if (!contacts || contacts.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-muted">Ingen kontakter fundet</td></tr>';
return;
}
tbody.innerHTML = contacts.map(contact => {
const initials = getInitials(contact.first_name, contact.last_name);
const statusBadge = contact.is_active
? '<span class="status-pill active">Aktiv</span>'
: '<span class="status-pill inactive">Inaktiv</span>';
const companyCount = contact.company_count || 0;
const companyNames = contact.company_names || [];
const companyDisplay = companyNames.length > 0
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
: '-';
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const mobileLine = contact.mobile
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.mobile)}
<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.mobile)}')">Ring op</button>
<button class="btn btn-sm btn-outline-primary py-0 px-2" onclick="event.stopPropagation(); openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(fullName)}', ${contact.id || 'null'})">SMS</button>
</div>`
: '';
const phoneLine = !contact.mobile
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.phone || '-')}
${contact.phone ? `<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.phone)}')">Ring op</button>` : ''}
</div>`
: '';
const smsLine = mobileLine || phoneLine;
const safeName = escapeHtml(`${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '-');
const safeDepartment = escapeHtml(contact.department || '-');
const safeEmail = escapeHtml(contact.email || '-');
const safeTitle = escapeHtml(contact.title || '-');
const companiesTitle = escapeHtml(companyNames.join(', '));
return `
<tr onclick="viewContact(${contact.id})">
<td>
<div class="d-flex align-items-center">
<div class="contact-avatar me-3">${initials}</div>
<div>
<div class="contact-name">${safeName}</div>
<div class="contact-subline">${safeDepartment}</div>
</div>
</div>
</td>
<td>
<div class="contact-info-main">${safeEmail}</div>
<div class="contact-quick-actions">${smsLine}</div>
</td>
<td class="text-muted">${safeTitle}</td>
<td>
<span class="company-count-chip" title="${companiesTitle}">
<i class="bi bi-building"></i>${companyCount}
</span>
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
</td>
<td>${statusBadge}</td>
<td class="text-end">
<div class="btn-group">
<button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); viewContact(${contact.id})" title="Vis kontakt">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); editContact(${contact.id})" title="Rediger kontakt">
<i class="bi bi-pencil"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function updatePagination(total) {
const start = currentPage * pageSize + 1;
const end = Math.min((currentPage + 1) * pageSize, total);
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
document.getElementById('showingEnd').textContent = end;
document.getElementById('totalCount').textContent = total;
// Update buttons
document.getElementById('prevBtn').disabled = currentPage === 0;
document.getElementById('nextBtn').disabled = end >= total;
}
function previousPage() {
if (currentPage > 0) {
currentPage--;
loadContacts();
}
}
function nextPage() {
if ((currentPage + 1) * pageSize < totalContacts) {
currentPage++;
loadContacts();
}
}
function viewContact(contactId) {
window.location.href = `/contacts/${contactId}`;
}
function editContact(contactId) {
// Load contact data and open edit modal
loadContactForEdit(contactId);
}
let contactsCurrentUserId = null;
async function ensureContactsCurrentUserId() {
if (contactsCurrentUserId !== null) return contactsCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
contactsCurrentUserId = Number(me?.id) || null;
return contactsCurrentUserId;
} catch (e) {
return null;
}
}
async function contactsCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureContactsCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
async function loadContactForEdit(contactId) {
try {
const response = await fetch(`/api/v1/contacts/${contactId}`);
if (!response.ok) throw new Error('Kunne ikke indlæse kontakt');
const contact = await response.json();
// Fill form
document.getElementById('editContactId').value = contactId;
document.getElementById('editFirstNameInput').value = contact.first_name || '';
document.getElementById('editLastNameInput').value = contact.last_name || '';
document.getElementById('editEmailInput').value = contact.email || '';
document.getElementById('editPhoneInput').value = contact.phone || '';
document.getElementById('editMobileInput').value = contact.mobile || '';
document.getElementById('editTitleInput').value = contact.title || '';
document.getElementById('editDepartmentInput').value = contact.department || '';
document.getElementById('editIsActiveInput').checked = contact.is_active || false;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editContactModal'));
modal.show();
} catch (error) {
console.error('Failed to load contact:', error);
alert('Fejl: Kunne ikke indlæse kontakt');
}
}
async function saveEditContact() {
const contactId = document.getElementById('editContactId').value;
const firstName = document.getElementById('editFirstNameInput').value.trim();
const lastName = document.getElementById('editLastNameInput').value.trim();
if (!firstName || !lastName) {
alert('Fornavn og efternavn er påkrævet');
return;
}
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
email: document.getElementById('editEmailInput').value || null,
phone: document.getElementById('editPhoneInput').value || null,
mobile: document.getElementById('editMobileInput').value || null,
title: document.getElementById('editTitleInput').value || null,
department: document.getElementById('editDepartmentInput').value || null,
is_active: document.getElementById('editIsActiveInput').checked
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme kontakt');
}
// Close modal and reload
const modal = bootstrap.Modal.getInstance(document.getElementById('editContactModal'));
modal.hide();
loadContacts();
} catch (error) {
console.error('Failed to save contact:', error);
alert('Fejl: ' + error.message);
}
}
async function loadCompaniesForSelect() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
availableCompanies = Array.isArray(data.customers)
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
: [];
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
renderSelectedCompanies();
} catch (error) {
console.error('Failed to load companies:', error);
}
}
function renderCompanyResults(query) {
const host = document.getElementById('companyResults');
if (!host) return;
const needle = String(query || '').trim().toLowerCase();
let list = availableCompanies;
if (needle) {
list = availableCompanies.filter((c) => c.name.toLowerCase().includes(needle));
}
list = list.slice(0, 80);
if (!list.length) {
host.innerHTML = '<div class="px-3 py-2 text-muted small">Ingen firmaer fundet</div>';
return;
}
host.innerHTML = list.map((c) => {
const selected = selectedCompanyIds.has(c.id);
return `
<button type="button" class="company-result-item ${selected ? 'selected' : ''}" onclick="toggleCompanySelection(${c.id})">
<span>${escapeHtml(c.name)}</span>
<span>${selected ? '<i class="bi bi-check2"></i>' : ''}</span>
</button>
`;
}).join('');
}
function toggleCompanySelection(companyId) {
const id = Number(companyId);
if (!Number.isFinite(id)) return;
if (selectedCompanyIds.has(id)) {
selectedCompanyIds.delete(id);
} else {
selectedCompanyIds.add(id);
}
renderSelectedCompanies();
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
}
function renderSelectedCompanies() {
const host = document.getElementById('selectedCompanies');
if (!host) return;
const selected = availableCompanies.filter((c) => selectedCompanyIds.has(c.id));
if (!selected.length) {
host.innerHTML = '<span class="text-muted small">Ingen firmaer valgt</span>';
return;
}
host.innerHTML = selected.map((c) => `
<span class="company-chip">
${escapeHtml(c.name)}
<button type="button" title="Fjern" onclick="toggleCompanySelection(${c.id})"><i class="bi bi-x-lg"></i></button>
</span>
`).join('');
}
function showCreateContactModal() {
// Reset form
document.getElementById('createContactForm').reset();
document.getElementById('isActiveInput').checked = true;
selectedCompanyIds = new Set();
const companySearchInput = document.getElementById('companySearchInput');
if (companySearchInput) {
companySearchInput.value = '';
}
renderCompanyResults('');
renderSelectedCompanies();
// Show modal
const modal = new bootstrap.Modal(document.getElementById('createContactModal'));
modal.show();
}
async function createContact() {
const firstName = document.getElementById('firstNameInput').value.trim();
const lastName = document.getElementById('lastNameInput').value.trim();
if (!firstName || !lastName) {
alert('Fornavn og efternavn er påkrævet');
return;
}
// Get selected company IDs
const companyIds = Array.from(selectedCompanyIds);
const contactData = {
first_name: firstName,
last_name: lastName,
email: document.getElementById('emailInput').value.trim() || null,
phone: document.getElementById('phoneInput').value.trim() || null,
mobile: document.getElementById('mobileInput').value.trim() || null,
title: document.getElementById('titleInput').value.trim() || null,
department: document.getElementById('departmentInput').value.trim() || null,
company_ids: companyIds,
is_primary: document.getElementById('isPrimaryInput').checked,
role: document.getElementById('roleInput').value.trim() || null,
notes: document.getElementById('notesInput').value.trim() || null,
is_active: document.getElementById('isActiveInput').checked
};
try {
const response = await fetch('/api/v1/contacts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(contactData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke oprette kontakt');
}
const newContact = await response.json();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
modal.hide();
// Reload contact list
await loadContacts();
// Show success message
alert('Kontakt oprettet succesfuldt!');
} catch (error) {
console.error('Failed to create contact:', error);
alert('Fejl ved oprettelse af kontakt: ' + error.message);
}
}
function getInitials(firstName, lastName) {
if (!firstName && !lastName) return '?';
const first = firstName ? firstName[0] : '';
const last = lastName ? lastName[0] : '';
return (first + last).toUpperCase();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
{% endblock %}