484 lines
18 KiB
HTML
484 lines
18 KiB
HTML
|
|
{% extends "shared/frontend/base.html" %}
|
||
|
|
|
||
|
|
{% block title %}Kontakter - BMC Hub{% endblock %}
|
||
|
|
|
||
|
|
{% block extra_css %}
|
||
|
|
<style>
|
||
|
|
.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);
|
||
|
|
}
|
||
|
|
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
{% endblock %}
|
||
|
|
|
||
|
|
{% block content %}
|
||
|
|
<div class="d-flex justify-content-between align-items-center mb-5">
|
||
|
|
<div>
|
||
|
|
<h2 class="fw-bold mb-1">Kontakter</h2>
|
||
|
|
<p class="text-muted mb-0">Administrer kontaktpersoner</p>
|
||
|
|
</div>
|
||
|
|
<div class="d-flex gap-3">
|
||
|
|
<input type="text" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon...">
|
||
|
|
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||
|
|
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</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">
|
||
|
|
<div class="table-responsive">
|
||
|
|
<table class="table table-hover align-middle">
|
||
|
|
<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" 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>
|
||
|
|
<select class="form-select" id="companySelect" multiple size="5">
|
||
|
|
<!-- Populated dynamically -->
|
||
|
|
</select>
|
||
|
|
<div class="form-text">Hold Ctrl/Cmd nede for at vælge flere firmaer</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>
|
||
|
|
{% endblock %}
|
||
|
|
|
||
|
|
{% block extra_js %}
|
||
|
|
<script>
|
||
|
|
let currentPage = 0;
|
||
|
|
let pageSize = 20;
|
||
|
|
let currentFilter = 'all';
|
||
|
|
let searchQuery = '';
|
||
|
|
let totalContacts = 0;
|
||
|
|
|
||
|
|
// Load contacts on page load
|
||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
||
|
|
loadContacts();
|
||
|
|
loadCompaniesForSelect();
|
||
|
|
|
||
|
|
// Search with debounce
|
||
|
|
let searchTimeout;
|
||
|
|
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||
|
|
clearTimeout(searchTimeout);
|
||
|
|
searchTimeout = setTimeout(() => {
|
||
|
|
searchQuery = e.target.value;
|
||
|
|
currentPage = 0;
|
||
|
|
loadContacts();
|
||
|
|
}, 300);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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>';
|
||
|
|
|
||
|
|
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 response = await fetch(`/api/v1/contacts?${params}`);
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
totalContacts = data.total;
|
||
|
|
displayContacts(data.contacts);
|
||
|
|
updatePagination(data.total);
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
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>';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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="badge bg-success bg-opacity-10 text-success">Aktiv</span>'
|
||
|
|
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">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 ? '...' : '')
|
||
|
|
: '-';
|
||
|
|
|
||
|
|
return `
|
||
|
|
<tr style="cursor: pointer;" onclick="viewContact(${contact.id})">
|
||
|
|
<td>
|
||
|
|
<div class="d-flex align-items-center">
|
||
|
|
<div class="contact-avatar me-3">${initials}</div>
|
||
|
|
<div>
|
||
|
|
<div class="fw-bold">${escapeHtml(contact.first_name + ' ' + contact.last_name)}</div>
|
||
|
|
<div class="small text-muted">${contact.department || '-'}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<div class="fw-medium">${contact.email || '-'}</div>
|
||
|
|
<div class="small text-muted">${contact.mobile || contact.phone || '-'}</div>
|
||
|
|
</td>
|
||
|
|
<td class="text-muted">${contact.title || '-'}</td>
|
||
|
|
<td>
|
||
|
|
<span class="badge bg-light text-dark border" title="${companyNames.join(', ')}">
|
||
|
|
<i class="bi bi-building me-1"></i>${companyCount}
|
||
|
|
</span>
|
||
|
|
${companyDisplay !== '-' ? '<div class="small text-muted">' + companyDisplay + '</div>' : ''}
|
||
|
|
</td>
|
||
|
|
<td>${statusBadge}</td>
|
||
|
|
<td class="text-end">
|
||
|
|
<div class="btn-group">
|
||
|
|
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewContact(${contact.id})">
|
||
|
|
<i class="bi bi-eye"></i>
|
||
|
|
</button>
|
||
|
|
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editContact(${contact.id})">
|
||
|
|
<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) {
|
||
|
|
// TODO: Open edit modal
|
||
|
|
console.log('Edit contact:', contactId);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadCompaniesForSelect() {
|
||
|
|
try {
|
||
|
|
const response = await fetch('/api/v1/customers?limit=1000');
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
const select = document.getElementById('companySelect');
|
||
|
|
select.innerHTML = data.customers.map(c =>
|
||
|
|
`<option value="${c.id}">${escapeHtml(c.name)}</option>`
|
||
|
|
).join('');
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to load companies:', error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function showCreateContactModal() {
|
||
|
|
// Reset form
|
||
|
|
document.getElementById('createContactForm').reset();
|
||
|
|
document.getElementById('isActiveInput').checked = true;
|
||
|
|
|
||
|
|
// 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 companySelect = document.getElementById('companySelect');
|
||
|
|
const companyIds = Array.from(companySelect.selectedOptions).map(opt => parseInt(opt.value));
|
||
|
|
|
||
|
|
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 %}
|