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.
This commit is contained in:
Christian 2026-04-01 21:34:58 +02:00
parent bc504b9257
commit 30d1be61eb
35 changed files with 6329 additions and 242 deletions

View File

@ -16,6 +16,11 @@ API_HOST=0.0.0.0
API_PORT=8001 # Changed from 8000 to avoid conflicts with other services API_PORT=8001 # Changed from 8000 to avoid conflicts with other services
ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker) ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Docker)
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=
FIRMAAPI_TIMEOUT_SECONDS=12
# ===================================================== # =====================================================
# SECURITY # SECURITY
# ===================================================== # =====================================================
@ -77,6 +82,7 @@ LINKS_READ_ONLY=true
LINKS_DRY_RUN=true LINKS_DRY_RUN=true
LINKS_DEAD_LINK_CHECK_ENABLED=true LINKS_DEAD_LINK_CHECK_ENABLED=true
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
LINKS_CHECK_TIMEOUT_SECONDS=5
# Vaultwarden (Bitwarden-compatible) # Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL= VAULTWARDEN_BASE_URL=

View File

@ -44,6 +44,11 @@ API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
API_RELOAD=false API_RELOAD=false
# FirmaAPI (CVR company lookup)
FIRMAAPI_BASE_URL=https://firmaapi.dk/api/v1
FIRMAAPI_API_KEY=
FIRMAAPI_TIMEOUT_SECONDS=12
# ===================================================== # =====================================================
# SECURITY - Production # SECURITY - Production
# ===================================================== # =====================================================
@ -86,6 +91,7 @@ LINKS_READ_ONLY=true
LINKS_DRY_RUN=true LINKS_DRY_RUN=true
LINKS_DEAD_LINK_CHECK_ENABLED=true LINKS_DEAD_LINK_CHECK_ENABLED=true
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60 LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES=60
LINKS_CHECK_TIMEOUT_SECONDS=5
# Vaultwarden (Bitwarden-compatible) # Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL= VAULTWARDEN_BASE_URL=

View File

@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
git \ git \
libpq-dev \ libpq-dev \
libzbar0 \
gcc \ gcc \
g++ \ g++ \
python3-dev \ python3-dev \

View File

@ -4,6 +4,53 @@
{% block extra_css %} {% block extra_css %}
<style> <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 { .filter-btn {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.1);
@ -21,6 +68,139 @@
border-color: var(--accent); 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 { .contact-avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -32,6 +212,7 @@
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
font-size: 0.9rem; font-size: 0.9rem;
box-shadow: inset 0 0 0 1px rgba(15, 76, 117, 0.12);
} }
.pagination-btn { .pagination-btn {
@ -52,22 +233,137 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-5"> <div class="d-flex justify-content-between align-items-center mb-5 contacts-toolbar">
<div> <div>
<h2 class="fw-bold mb-1">Kontakter</h2> <h2 class="fw-bold mb-1">Kontakter</h2>
<p class="text-muted mb-0">Administrer kontaktpersoner</p> <p class="text-muted mb-0">Administrer kontaktpersoner</p>
</div> </div>
<div class="d-flex gap-3"> <div class="toolbar-search-slot">
<input type="text" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon..."> <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()"> <button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt <i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button> </button>
</div> </div>
</div>
<div class="mb-4 d-flex gap-2 flex-wrap"> <div class="mb-4 d-flex gap-2 flex-wrap">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')"> <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
@ -81,9 +377,9 @@
</button> </button>
</div> </div>
<div class="card p-4"> <div class="card p-4 contacts-shell">
<div class="table-responsive"> <div class="table-responsive contacts-table-wrap">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle contacts-table">
<thead> <thead>
<tr> <tr>
<th>Navn</th> <th>Navn</th>
@ -123,7 +419,7 @@
</div> </div>
<!-- Create Contact Modal --> <!-- Create Contact Modal -->
<div class="modal fade" id="createContactModal" tabindex="-1"> <div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -175,10 +471,19 @@
<div class="col-12"> <div class="col-12">
<label class="form-label">Firmaer</label> <label class="form-label">Firmaer</label>
<select class="form-select" id="companySelect" multiple size="5"> <div class="company-picker">
<!-- Populated dynamically --> <input
</select> type="search"
<div class="form-text">Hold Ctrl/Cmd nede for at vælge flere firmaer</div> 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>
<div class="col-12"> <div class="col-12">
@ -292,22 +597,72 @@ let pageSize = 20;
let currentFilter = 'all'; let currentFilter = 'all';
let searchQuery = ''; let searchQuery = '';
let totalContacts = 0; let totalContacts = 0;
let searchTimeout = null;
let currentRequestController = null;
let lastLoadedQueryKey = '';
let availableCompanies = [];
let selectedCompanyIds = new Set();
// Load contacts on page load // Load contacts on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadContacts(); loadContacts();
loadCompaniesForSelect(); loadCompaniesForSelect();
// Search with debounce const searchInput = document.getElementById('searchInput');
let searchTimeout; const clearBtn = document.getElementById('searchClearBtn');
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimeout); const triggerSearch = () => {
searchTimeout = setTimeout(() => { const nextSearch = searchInput.value.trim();
searchQuery = e.target.value; if (nextSearch === searchQuery) {
toggleClearButton(nextSearch);
return;
}
searchQuery = nextSearch;
currentPage = 0; currentPage = 0;
toggleClearButton(searchQuery);
loadContacts(); loadContacts();
};
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
toggleClearButton(e.target.value.trim());
searchTimeout = setTimeout(() => {
triggerSearch();
}, 300); }, 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) { function setFilter(filter) {
@ -327,6 +682,11 @@ async function loadContacts() {
const tbody = document.getElementById('contactsTableBody'); 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>'; 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 { try {
// Build query parameters // Build query parameters
let params = new URLSearchParams({ let params = new URLSearchParams({
@ -344,7 +704,13 @@ async function loadContacts() {
params.append('is_active', 'false'); params.append('is_active', 'false');
} }
const response = await fetch(`/api/v1/contacts?${params}`); 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(); const data = await response.json();
totalContacts = data.total; totalContacts = data.total;
@ -352,11 +718,20 @@ async function loadContacts() {
updatePagination(data.total); updatePagination(data.total);
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
return;
}
console.error('Failed to load contacts:', 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>'; 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) { function displayContacts(contacts) {
const tbody = document.getElementById('contactsTableBody'); const tbody = document.getElementById('contactsTableBody');
@ -368,8 +743,8 @@ function displayContacts(contacts) {
tbody.innerHTML = contacts.map(contact => { tbody.innerHTML = contacts.map(contact => {
const initials = getInitials(contact.first_name, contact.last_name); const initials = getInitials(contact.first_name, contact.last_name);
const statusBadge = contact.is_active const statusBadge = contact.is_active
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' ? '<span class="status-pill active">Aktiv</span>'
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>'; : '<span class="status-pill inactive">Inaktiv</span>';
const companyCount = contact.company_count || 0; const companyCount = contact.company_count || 0;
const companyNames = contact.company_names || []; const companyNames = contact.company_names || [];
@ -389,36 +764,41 @@ function displayContacts(contacts) {
</div>` </div>`
: ''; : '';
const smsLine = mobileLine || phoneLine; 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 ` return `
<tr style="cursor: pointer;" onclick="viewContact(${contact.id})"> <tr onclick="viewContact(${contact.id})">
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="contact-avatar me-3">${initials}</div> <div class="contact-avatar me-3">${initials}</div>
<div> <div>
<div class="fw-bold">${escapeHtml(contact.first_name + ' ' + contact.last_name)}</div> <div class="contact-name">${safeName}</div>
<div class="small text-muted">${contact.department || '-'}</div> <div class="contact-subline">${safeDepartment}</div>
</div> </div>
</div> </div>
</td> </td>
<td> <td>
<div class="fw-medium">${contact.email || '-'}</div> <div class="contact-info-main">${safeEmail}</div>
${smsLine} <div class="contact-quick-actions">${smsLine}</div>
</td> </td>
<td class="text-muted">${contact.title || '-'}</td> <td class="text-muted">${safeTitle}</td>
<td> <td>
<span class="badge bg-light text-dark border" title="${companyNames.join(', ')}"> <span class="company-count-chip" title="${companiesTitle}">
<i class="bi bi-building me-1"></i>${companyCount} <i class="bi bi-building"></i>${companyCount}
</span> </span>
${companyDisplay !== '-' ? '<div class="small text-muted">' + companyDisplay + '</div>' : ''} ${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
</td> </td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td class="text-end"> <td class="text-end">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewContact(${contact.id})"> <button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); viewContact(${contact.id})" title="Vis kontakt">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editContact(${contact.id})"> <button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); editContact(${contact.id})" title="Rediger kontakt">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
</div> </div>
@ -581,19 +961,87 @@ async function loadCompaniesForSelect() {
const response = await fetch('/api/v1/customers?limit=1000'); const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json(); const data = await response.json();
const select = document.getElementById('companySelect'); availableCompanies = Array.isArray(data.customers)
select.innerHTML = data.customers.map(c => ? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
`<option value="${c.id}">${escapeHtml(c.name)}</option>` : [];
).join(''); renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
renderSelectedCompanies();
} catch (error) { } catch (error) {
console.error('Failed to load companies:', 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() { function showCreateContactModal() {
// Reset form // Reset form
document.getElementById('createContactForm').reset(); document.getElementById('createContactForm').reset();
document.getElementById('isActiveInput').checked = true; document.getElementById('isActiveInput').checked = true;
selectedCompanyIds = new Set();
const companySearchInput = document.getElementById('companySearchInput');
if (companySearchInput) {
companySearchInput.value = '';
}
renderCompanyResults('');
renderSelectedCompanies();
// Show modal // Show modal
const modal = new bootstrap.Modal(document.getElementById('createContactModal')); const modal = new bootstrap.Modal(document.getElementById('createContactModal'));
@ -610,8 +1058,7 @@ async function createContact() {
} }
// Get selected company IDs // Get selected company IDs
const companySelect = document.getElementById('companySelect'); const companyIds = Array.from(selectedCompanyIds);
const companyIds = Array.from(companySelect.selectedOptions).map(opt => parseInt(opt.value));
const contactData = { const contactData = {
first_name: firstName, first_name: firstName,

View File

@ -31,6 +31,11 @@ class Settings(BaseSettings):
APIGW_TOKEN: str = "" APIGW_TOKEN: str = ""
APIGW_TIMEOUT_SECONDS: int = 12 APIGW_TIMEOUT_SECONDS: int = 12
# FirmaAPI (CVR company data)
FIRMAAPI_BASE_URL: str = "https://firmaapi.dk/api/v1"
FIRMAAPI_API_KEY: str = ""
FIRMAAPI_TIMEOUT_SECONDS: int = 12
# Security # Security
SECRET_KEY: str = "dev-secret-key-change-in-production" SECRET_KEY: str = "dev-secret-key-change-in-production"
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production" JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
@ -76,6 +81,7 @@ class Settings(BaseSettings):
LINKS_DRY_RUN: bool = True LINKS_DRY_RUN: bool = True
LINKS_DEAD_LINK_CHECK_ENABLED: bool = True LINKS_DEAD_LINK_CHECK_ENABLED: bool = True
LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60 LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES: int = 60
LINKS_CHECK_TIMEOUT_SECONDS: int = 5
# Vaultwarden (Bitwarden-compatible) # Vaultwarden (Bitwarden-compatible)
VAULTWARDEN_BASE_URL: str = "" VAULTWARDEN_BASE_URL: str = ""

View File

@ -12,7 +12,7 @@ import asyncio
import aiohttp import aiohttp
from urllib.parse import quote from urllib.parse import quote
from app.core.database import execute_query, execute_query_single, execute_update from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
from app.core.config import settings from app.core.config import settings
from app.services.cvr_service import get_cvr_service from app.services.cvr_service import get_cvr_service
from app.services.customer_activity_logger import CustomerActivityLogger from app.services.customer_activity_logger import CustomerActivityLogger
@ -81,7 +81,8 @@ async def list_customers(
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
search: Optional[str] = Query(default=None), search: Optional[str] = Query(default=None),
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
is_active: Optional[bool] = Query(default=None) is_active: Optional[bool] = Query(default=None),
vip: Optional[bool] = Query(default=None)
): ):
""" """
List customers with pagination and filtering List customers with pagination and filtering
@ -138,6 +139,19 @@ async def list_customers(
query += " AND c.is_active = %s" query += " AND c.is_active = %s"
params.append(is_active) params.append(is_active)
# Add VIP filter (customer tagged with "vip")
if vip is True:
query += """
AND EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'customer'
AND et.entity_id = c.id
AND LOWER(t.name) = 'vip'
)
"""
query += """ query += """
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
ORDER BY c.name ORDER BY c.name
@ -170,6 +184,18 @@ async def list_customers(
count_query += " AND is_active = %s" count_query += " AND is_active = %s"
count_params.append(is_active) count_params.append(is_active)
if vip is True:
count_query += """
AND EXISTS (
SELECT 1
FROM entity_tags et
JOIN tags t ON t.id = et.tag_id
WHERE et.entity_type = 'customer'
AND et.entity_id = customers.id
AND LOWER(t.name) = 'vip'
)
"""
count_result = execute_query_single(count_query, tuple(count_params)) count_result = execute_query_single(count_query, tuple(count_params))
total = count_result['total'] if count_result else 0 total = count_result['total'] if count_result else 0

View File

@ -245,6 +245,9 @@
</div> </div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a class="btn btn-light btn-sm" href="/links?customer_id={{ customer_id }}" title="Se links/endpoints for denne kunde">
<i class="bi bi-link-45deg me-2"></i>Links
</a>
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde"> <button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note <i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
</button> </button>
@ -309,6 +312,11 @@
<i class="bi bi-people"></i>Kontakter <i class="bi bi-people"></i>Kontakter
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#cases">
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt"> <a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt <i class="bi bi-chat-left-text"></i>Kontakt
@ -344,6 +352,11 @@
<i class="bi bi-hdd"></i>Hardware <i class="bi bi-hdd"></i>Hardware
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#links">
<i class="bi bi-link-45deg"></i>Links
</a>
</li>
<li class="nav-item d-none" id="nextcloudTabNav"> <li class="nav-item d-none" id="nextcloudTabNav">
<a class="nav-link" data-bs-toggle="tab" href="#nextcloud"> <a class="nav-link" data-bs-toggle="tab" href="#nextcloud">
<i class="bi bi-cloud"></i>Nextcloud <i class="bi bi-cloud"></i>Nextcloud
@ -519,6 +532,48 @@
</div> </div>
</div> </div>
<!-- Cases Tab -->
<div class="tab-pane fade" id="cases">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kundens sager</h5>
<small class="text-muted">Alle sager knyttet til denne kunde</small>
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-primary" href="/sag/new?customer_id={{ customer_id }}">
<i class="bi bi-plus-lg me-2"></i>Opret sag
</a>
<a class="btn btn-sm btn-outline-secondary" href="/sag?customer_id={{ customer_id }}">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn i sagsmodul
</a>
</div>
</div>
<div class="table-responsive" id="customerCasesContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="customerCasesEmpty" class="text-center py-5 text-muted d-none">
Ingen sager fundet for denne kunde
</div>
</div>
<!-- Kontakt Tab --> <!-- Kontakt Tab -->
<div class="tab-pane fade" id="kontakt"> <div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
@ -748,6 +803,42 @@
</div> </div>
</div> </div>
<!-- Links Tab -->
<div class="tab-pane fade" id="links">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Links / Endpoints</h5>
<small class="text-muted">Driftslinks knyttet til denne kunde</small>
</div>
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id={{ customer_id }}">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn fuld visning
</a>
</div>
<div class="table-responsive" id="customerLinksContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Type</th>
<th>Mål</th>
<th>Miljø</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="customerLinksEmpty" class="text-center py-5 text-muted d-none">
Ingen links fundet for denne kunde
</div>
</div>
<!-- Nextcloud Tab --> <!-- Nextcloud Tab -->
<div class="tab-pane fade d-none" id="nextcloud"> <div class="tab-pane fade d-none" id="nextcloud">
{% include "modules/nextcloud/templates/tab.html" %} {% include "modules/nextcloud/templates/tab.html" %}
@ -1210,6 +1301,11 @@ let customerKontaktFilter = 'all';
let eventListenersAdded = false; let eventListenersAdded = false;
function getAuthHeaders() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
if (eventListenersAdded) { if (eventListenersAdded) {
console.log('Event listeners already added, skipping...'); console.log('Event listeners already added, skipping...');
@ -1226,6 +1322,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { once: false });
} }
const casesTab = document.querySelector('a[href="#cases"]');
if (casesTab) {
casesTab.addEventListener('shown.bs.tab', () => {
loadCustomerCases();
}, { once: false });
}
const kontaktTab = document.querySelector('a[href="#kontakt"]'); const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) { if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => { kontaktTab.addEventListener('shown.bs.tab', () => {
@ -1266,6 +1369,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { once: false });
} }
const linksTab = document.querySelector('a[href="#links"]');
if (linksTab) {
linksTab.addEventListener('shown.bs.tab', () => {
loadCustomerLinks();
}, { once: false });
}
// Load activity when tab is shown // Load activity when tab is shown
const activityTab = document.querySelector('a[href="#activity"]'); const activityTab = document.querySelector('a[href="#activity"]');
if (activityTab) { if (activityTab) {
@ -2315,6 +2425,107 @@ async function loadContacts() {
} }
} }
async function loadCustomerCases() {
const container = document.getElementById('customerCasesContainer');
const empty = document.getElementById('customerCasesEmpty');
if (!container || !empty) {
return;
}
container.classList.remove('d-none');
empty.classList.add('d-none');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
`;
try {
const response = await fetch(`/api/v1/sag?customer_id=${customerId}`);
const cases = await response.json();
if (!response.ok) {
throw new Error(cases?.detail || 'Kunne ikke hente kundens sager');
}
const list = Array.isArray(cases) ? cases : [];
if (!list.length) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
const rows = list.map((item) => {
const id = Number(item.id) || 0;
const title = escapeHtml(item.titel || '-');
const statusRaw = String(item.status || 'ukendt');
const statusLabel = escapeHtml(statusRaw);
const priority = escapeHtml(item.priority || 'normal');
const created = item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '-';
const statusClass =
statusRaw.toLowerCase() === 'lukket' ? 'bg-success-subtle text-success-emphasis' :
statusRaw.toLowerCase() === 'afventer' ? 'bg-warning-subtle text-warning-emphasis' :
'bg-primary-subtle text-primary-emphasis';
return `
<tr>
<td><a href="/sag/${id}" class="fw-semibold text-decoration-none">#${id}</a></td>
<td>${title}</td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
<td><span class="badge bg-light text-dark border">${priority}</span></td>
<td>${created}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/sag/${id}" title="Åbn sag">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>SagsID</th>
<th>Titel</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
} catch (error) {
console.error('Failed to load customer cases:', error);
container.innerHTML = `<div class="alert alert-danger mb-0"><i class="bi bi-exclamation-circle me-2"></i>${escapeHtml(error.message || 'Fejl ved hentning af sager')}</div>`;
}
}
let subscriptionsLoaded = false; let subscriptionsLoaded = false;
async function loadSubscriptions() { async function loadSubscriptions() {
@ -2376,6 +2587,7 @@ async function loadCustomerPipeline() {
let customerHardware = []; let customerHardware = [];
let hardwareLocationsById = {}; let hardwareLocationsById = {};
let customerLinks = [];
function getHardwareGroupLabel(item, groupBy) { function getHardwareGroupLabel(item, groupBy) {
if (groupBy === 'location') { if (groupBy === 'location') {
@ -2548,6 +2760,109 @@ document.addEventListener('change', (event) => {
} }
}); });
function renderCustomerLinksTable() {
const container = document.getElementById('customerLinksContainer');
const empty = document.getElementById('customerLinksEmpty');
if (!container || !empty) return;
if (!customerLinks.length) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
container.classList.remove('d-none');
empty.classList.add('d-none');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Type</th>
<th>Mål</th>
<th>Miljø</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
${customerLinks.map((link) => {
const type = (link.type || 'http').toUpperCase();
const target = link.url || link.host || '-';
const environment = link.environment || 'prod';
return `
<tr>
<td class="fw-semibold">${escapeHtml(link.name || 'Uden navn')}</td>
<td><span class="badge text-bg-secondary">${escapeHtml(type)}</span></td>
<td>${escapeHtml(target)}</td>
<td>${escapeHtml(environment)}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/links?customer_id=${customerId}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
async function loadCustomerLinks() {
const container = document.getElementById('customerLinksContainer');
const empty = document.getElementById('customerLinksEmpty');
if (!container || !empty) return;
container.classList.remove('d-none');
empty.classList.add('d-none');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Type</th>
<th>Mål</th>
<th>Miljø</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-center py-4"><div class="spinner-border text-primary"></div></td>
</tr>
</tbody>
</table>
`;
try {
const response = await fetch(`/api/v1/links?customer_id=${customerId}`, {
headers: {
...getAuthHeaders()
},
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error('Ingen adgang til links. Log ind igen eller tjek links.read permission.');
}
if (response.status === 404) {
throw new Error('Links-endpoint ikke fundet (modul ikke aktivt eller API ikke genstartet).');
}
throw new Error('Kunne ikke hente links');
}
const links = await response.json();
customerLinks = Array.isArray(links) ? links : [];
renderCustomerLinksTable();
} catch (error) {
console.error('Failed to load customer links:', error);
container.classList.add('d-none');
empty.classList.remove('d-none');
empty.textContent = error.message || 'Kunne ikke hente links for kunden';
}
}
function renderCustomerPipeline(opportunities) { function renderCustomerPipeline(opportunities) {
const tbody = document.getElementById('customerOpportunitiesTable'); const tbody = document.getElementById('customerOpportunitiesTable');
if (!opportunities || opportunities.length === 0) { if (!opportunities || opportunities.length === 0) {

View File

@ -4,6 +4,53 @@
{% block extra_css %} {% block extra_css %}
<style> <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 { .filter-btn {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.1);
@ -19,26 +66,56 @@
color: white; color: white;
border-color: var(--accent); 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> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-5"> <div class="d-flex justify-content-between align-items-center mb-5 customers-toolbar">
<div> <div>
<h2 class="fw-bold mb-1">Kunder</h2> <h2 class="fw-bold mb-1">Kunder</h2>
<p class="text-muted mb-0">Administrer dine kunder</p> <p class="text-muted mb-0">Administrer dine kunder</p>
</div> </div>
<div class="d-flex gap-3"> <div class="toolbar-search-slot">
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde..."> <div class="search-wrap">
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button> <input type="search" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, kontakt eller e-mail..." autocomplete="off" spellcheck="false">
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
<i class="bi bi-x-lg"></i>
</button>
</div> </div>
</div> </div>
<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 class="mb-4 d-flex gap-2"> <div class="mb-4 d-flex gap-2">
<button class="filter-btn active">Alle Kunder</button> <button class="filter-btn active" data-filter="all" type="button">Alle Kunder</button>
<button class="filter-btn">Aktive</button> <button class="filter-btn" data-filter="active" type="button">Aktive</button>
<button class="filter-btn">Inaktive</button> <button class="filter-btn" data-filter="inactive" type="button">Inaktive</button>
<button class="filter-btn">VIP</button> <button class="filter-btn" data-filter="vip" type="button">VIP</button>
</div> </div>
<div class="card p-4"> <div class="card p-4">
@ -73,55 +150,391 @@
</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> <script>
let currentPage = 1; let currentPage = 1;
const pageSize = 50; const pageSize = 50;
let totalCustomers = 0; let totalCustomers = 0;
let searchTerm = ''; let searchTerm = '';
let searchTimeout = null; let searchTimeout = null;
let currentRequestController = null;
let lastLoadedQueryKey = '';
let createCustomerModal = null;
let activeFilter = 'all';
// Load customers on page load // Load customers on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadCustomers(); loadCustomers();
createCustomerModal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
// Setup search with debounce // Setup search with debounce
const searchInput = document.getElementById('searchInput'); 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) => { searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
toggleClearButton(e.target.value.trim());
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
searchTerm = e.target.value; triggerSearch();
loadCustomers(1);
}, 300); }, 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) { async function loadCustomers(page = 1) {
currentPage = page; currentPage = page;
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
if (currentRequestController) {
currentRequestController.abort();
}
currentRequestController = new AbortController();
try { try {
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`; let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
if (searchTerm) { if (searchTerm) {
url += `&search=${encodeURIComponent(searchTerm)}`; url += `&search=${encodeURIComponent(searchTerm)}`;
} }
const response = await fetch(url);
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(); const data = await response.json();
lastLoadedQueryKey = queryKey;
totalCustomers = data.total; totalCustomers = data.total;
renderCustomers(data.customers); renderCustomers(data.customers);
renderPagination(); renderPagination();
updateCount(); updateCount();
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
return;
}
console.error('Error loading customers:', error); console.error('Error loading customers:', error);
document.getElementById('customersTableBody').innerHTML = ` document.getElementById('customersTableBody').innerHTML = `
<tr><td colspan="6" class="text-center text-danger py-5"> <tr><td colspan="6" class="text-center text-danger py-5">
❌ Fejl ved indlæsning: ${error.message} ❌ Fejl ved indlæsning: ${error.message}
</td></tr> </td></tr>
`; `;
} finally {
currentRequestController = null;
} }
} }
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) { function renderCustomers(customers) {
const tbody = document.getElementById('customersTableBody'); const tbody = document.getElementById('customersTableBody');
@ -139,6 +552,13 @@ function renderCustomers(customers) {
const statusBadge = customer.is_active ? const statusBadge = customer.is_active ?
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' : '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</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 ` return `
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;"> <tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
@ -146,21 +566,21 @@ function renderCustomers(customers) {
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold" <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);"> style="width: 40px; height: 40px; color: var(--accent);">
${initials} ${safeInitials}
</div> </div>
<div> <div>
<div class="fw-bold">${customer.name || '-'}</div> <div class="fw-bold">${safeName}</div>
<div class="small text-muted">${customer.address || '-'}</div> <div class="small text-muted">${safeAddress}</div>
</div> </div>
</div> </div>
</td> </td>
<td> <td>
<div class="fw-medium">${customer.contact_name || '-'}</div> <div class="fw-medium">${safeContactName}</div>
<div class="small text-muted">${customer.contact_phone || '-'}</div> <div class="small text-muted">${safeContactPhone}</div>
</td> </td>
<td class="text-muted">${customer.cvr_number || '-'}</td> <td class="text-muted">${safeCvr}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td class="text-muted">${customer.email || '-'}</td> <td class="text-muted">${safeEmail}</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'" onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
@ -236,6 +656,11 @@ function renderPagination() {
} }
function updateCount() { function updateCount() {
if (totalCustomers === 0) {
document.getElementById('customerCount').textContent = 'Ingen kunder fundet';
return;
}
const start = (currentPage - 1) * pageSize + 1; const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalCustomers); const end = Math.min(currentPage * pageSize, totalCustomers);
document.getElementById('customerCount').textContent = document.getElementById('customerCount').textContent =

View File

@ -4,7 +4,7 @@ API endpoints for email viewing, classification, and rule management
""" """
import logging import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
@ -164,6 +164,45 @@ class CreateSagFromEmailRequest(BaseModel):
priority: Optional[str] = None priority: Optional[str] = None
ansvarlig_bruger_id: Optional[int] = None ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None assigned_group_id: Optional[int] = None
class EmailReadStateUpdate(BaseModel):
is_read: bool
def _can_user_mark_case_email_read(user_id: Optional[int], linked_case_id: Optional[int]) -> bool:
"""Allow read-marking only for assignee user or assignee group members."""
if not linked_case_id:
# Non-case emails can still be marked read.
return True
if not user_id:
return False
case_row = execute_query_single(
"""
SELECT ansvarlig_bruger_id, assigned_group_id
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
(linked_case_id,),
) or {}
assigned_user_id = case_row.get("ansvarlig_bruger_id")
assigned_group_id = case_row.get("assigned_group_id")
if assigned_user_id is not None and int(assigned_user_id) == int(user_id):
return True
if assigned_group_id is not None:
user_group = execute_query_single(
"SELECT 1 FROM user_groups WHERE user_id = %s AND group_id = %s LIMIT 1",
(user_id, assigned_group_id),
)
if user_group:
return True
return False
created_by_user_id: int = 1 created_by_user_id: int = 1
relation_type: str = "mail" relation_type: str = "mail"
@ -369,7 +408,7 @@ async def list_emails(
@router.get("/emails/{email_id:int}", response_model=EmailDetail) @router.get("/emails/{email_id:int}", response_model=EmailDetail)
async def get_email(email_id: int): async def get_email(email_id: int, request: Request):
"""Get email detail by ID""" """Get email detail by ID"""
try: try:
query = """ query = """
@ -397,9 +436,14 @@ async def get_email(email_id: int):
attachments = execute_query(att_query, (email_id,)) attachments = execute_query(att_query, (email_id,))
email_data['attachments'] = attachments or [] email_data['attachments'] = attachments or []
# Mark as read user_id = getattr(request.state, "user_id", None)
linked_case_id = email_data.get("linked_case_id")
can_mark_read = _can_user_mark_case_email_read(user_id, linked_case_id)
if not bool(email_data.get("is_read")) and can_mark_read:
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s" update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
execute_update(update_query, (email_id,)) execute_update(update_query, (email_id,))
email_data["is_read"] = True
return email_data return email_data
@ -410,6 +454,38 @@ async def get_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.patch("/emails/{email_id:int}/read-state")
async def update_email_read_state(email_id: int, payload: EmailReadStateUpdate, request: Request):
"""Toggle read/unread state for an email.
Marking as read on case-linked emails is restricted to case assignee user/group.
"""
try:
row = execute_query_single(
"SELECT id, linked_case_id, is_read FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Email not found")
user_id = getattr(request.state, "user_id", None)
if payload.is_read:
can_mark_read = _can_user_mark_case_email_read(user_id, row.get("linked_case_id"))
if not can_mark_read:
raise HTTPException(status_code=403, detail="Email kan ikke markeres som laest: sag er ikke tildelt dig/din gruppe")
execute_update(
"UPDATE email_messages SET is_read = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(payload.is_read, email_id),
)
return {"success": True, "email_id": email_id, "is_read": payload.is_read}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating read-state for email {email_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/mark-processed") @router.post("/emails/{email_id}/mark-processed")
async def mark_email_processed(email_id: int): async def mark_email_processed(email_id: int):
"""Mark email as processed and move to 'Processed' folder""" """Mark email as processed and move to 'Processed' folder"""

View File

@ -77,7 +77,7 @@ async def _process_reminder_queue():
# Get assigned user name # Get assigned user name
assigned_user = None assigned_user = None
if event['ansvarlig_bruger_id']: if event['ansvarlig_bruger_id']:
user_query = "SELECT full_name FROM users WHERE id = %s" user_query = "SELECT full_name FROM users WHERE user_id = %s"
user = execute_query(user_query, (event['ansvarlig_bruger_id'],)) user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
assigned_user = user[0]['full_name'] if user else None assigned_user = user[0]['full_name'] if user else None
@ -174,7 +174,7 @@ async def _process_time_based_reminders():
# Get assigned user name # Get assigned user name
assigned_user = None assigned_user = None
if reminder['ansvarlig_bruger_id']: if reminder['ansvarlig_bruger_id']:
user_query = "SELECT full_name FROM users WHERE id = %s" user_query = "SELECT full_name FROM users WHERE user_id = %s"
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],)) user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
assigned_user = user[0]['full_name'] if user else None assigned_user = user[0]['full_name'] if user else None

View File

@ -20,9 +20,13 @@ from app.modules.links.models.schemas import (
LinkCategory, LinkCategory,
LinkCategoryCreate, LinkCategoryCreate,
LinkCreate, LinkCreate,
LinkLatestStatus,
LinkVaultResolveRequest,
LinkVaultResolveResponse,
LinkUpdate, LinkUpdate,
RelevantLink, RelevantLink,
) )
from app.services.vaultwarden_service import resolve_vault_credentials
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -113,6 +117,29 @@ async def list_links(
return [_with_categories(row) for row in rows] return [_with_categories(row) for row in rows]
@router.get("/links/status/latest", response_model=List[LinkLatestStatus])
async def list_latest_link_status(
link_id: Optional[int] = Query(None),
current_user: dict = Depends(require_permission("links.read")),
):
del current_user
rows = execute_query(
"""
SELECT DISTINCT ON (ls.link_id)
ls.link_id,
ls.status,
ls.checked_at,
ls.details
FROM link_status_checks ls
WHERE (%s IS NULL OR ls.link_id = %s)
ORDER BY ls.link_id, ls.checked_at DESC
""",
(link_id, link_id),
) or []
return rows
@router.get("/links/{link_id}", response_model=Link) @router.get("/links/{link_id}", response_model=Link)
async def get_link(link_id: int, current_user: dict = Depends(require_permission("links.read"))): async def get_link(link_id: int, current_user: dict = Depends(require_permission("links.read"))):
del current_user del current_user
@ -277,3 +304,51 @@ async def access_link(
) )
return action_result return action_result
@router.post("/links/{link_id}/vault/resolve", response_model=LinkVaultResolveResponse)
async def resolve_link_vault(
link_id: int,
payload: LinkVaultResolveRequest,
current_user: dict = Depends(require_permission("links.use")),
):
rows = execute_query("SELECT * FROM links WHERE id = %s AND deleted_at IS NULL", (link_id,)) or []
if not rows:
raise HTTPException(status_code=404, detail="Link not found")
link_row = rows[0]
fallback_item_ids = link_row.get("vault_item_ids") or []
if not isinstance(fallback_item_ids, list):
fallback_item_ids = []
result = await resolve_vault_credentials(
preferred_item_id=payload.item_id or link_row.get("vault_item_id"),
fallback_item_ids=[str(item) for item in fallback_item_ids if item],
search_hint=payload.search_hint or link_row.get("host") or link_row.get("url") or link_row.get("name"),
)
log_access(
link_id=link_id,
user_id=current_user["id"],
action_type="vault.resolve",
case_id=link_row.get("case_id"),
customer_id=link_row.get("customer_id"),
metadata={
"status": result.get("status"),
"configured": result.get("configured"),
"checked_item_ids": result.get("checked_item_ids") or [],
},
)
return result
@router.post("/links/health/run")
async def run_links_health_check(
current_user: dict = Depends(require_permission("links.diagnose")),
):
del current_user
from app.modules.links.jobs.dead_link_check import check_links_health
result = await check_links_health()
return {"status": "ok", "result": result}

View File

@ -1,18 +1,143 @@
import asyncio
import json
import logging import logging
import time
from typing import Optional, Tuple
import httpx
from app.core.config import settings
from app.core.database import execute_query from app.core.database import execute_query
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def check_links_health(): def _normalize_http_url(url: Optional[str], host: Optional[str]) -> Optional[str]:
rows = execute_query("SELECT id, type, url, host FROM links WHERE deleted_at IS NULL", ()) or [] candidate = (url or "").strip()
for row in rows: if not candidate and host:
candidate = host.strip()
if not candidate:
return None
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
return f"http://{candidate}"
async def _check_http(client: httpx.AsyncClient, url: str) -> Tuple[str, dict]:
started = time.perf_counter()
try:
response = await client.get(url)
elapsed_ms = int((time.perf_counter() - started) * 1000)
status = "ok" if response.status_code < 400 else "down"
return status, {
"checker": "http",
"url": str(response.url),
"http_status": response.status_code,
"elapsed_ms": elapsed_ms,
}
except Exception as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return "down", {
"checker": "http",
"url": url,
"error": str(exc),
"elapsed_ms": elapsed_ms,
}
async def _check_tcp(host: str, port: int, timeout_seconds: int, checker: str) -> Tuple[str, dict]:
started = time.perf_counter()
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=float(timeout_seconds))
del reader
writer.close()
await writer.wait_closed()
elapsed_ms = int((time.perf_counter() - started) * 1000)
return "ok", {
"checker": checker,
"host": host,
"port": port,
"elapsed_ms": elapsed_ms,
}
except Exception as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return "down", {
"checker": checker,
"host": host,
"port": port,
"error": str(exc),
"elapsed_ms": elapsed_ms,
}
async def _evaluate_link(row: dict, client: httpx.AsyncClient, timeout_seconds: int) -> Tuple[str, dict]:
link_type = row.get("type")
host = row.get("host")
port = row.get("port")
url = row.get("url")
if link_type == "http":
normalized_url = _normalize_http_url(url, host)
if not normalized_url:
return "unknown", {"checker": "http", "reason": "missing_url_or_host"}
return await _check_http(client, normalized_url)
if link_type == "ssh":
if not host:
return "unknown", {"checker": "tcp", "reason": "missing_host", "type": "ssh"}
return await _check_tcp(host, int(port or 22), timeout_seconds, "tcp-ssh")
if link_type == "rdp":
if not host:
return "unknown", {"checker": "tcp", "reason": "missing_host", "type": "rdp"}
return await _check_tcp(host, int(port or 3389), timeout_seconds, "tcp-rdp")
if link_type == "command":
return "unknown", {"checker": "command", "reason": "not_probeable"}
return "unknown", {"checker": "unknown", "reason": f"unsupported_type:{link_type}"}
def _persist_status(link_id: int, status: str, details: dict) -> None:
execute_query( execute_query(
""" """
INSERT INTO link_status_checks (link_id, status, details) INSERT INTO link_status_checks (link_id, status, details)
VALUES (%s, %s, %s::jsonb) VALUES (%s, %s, %s::jsonb)
""", """,
(row["id"], "unknown", '{"reason":"initial implementation placeholder"}'), (link_id, status, json.dumps(details or {})),
) )
logger.info("✅ Links health placeholder executed for %s links", len(rows))
async def check_links_health():
rows = execute_query(
"SELECT id, type, url, host, port FROM links WHERE deleted_at IS NULL",
(),
) or []
timeout_seconds = max(1, int(settings.LINKS_CHECK_TIMEOUT_SECONDS))
if settings.LINKS_DRY_RUN:
for row in rows:
_persist_status(int(row["id"]), "unknown", {"reason": "dry_run_enabled"})
logger.info("✅ Links health check skipped by dry-run for %s links", len(rows))
return {"checked": len(rows), "ok": 0, "down": 0, "unknown": len(rows), "dry_run": True}
summary = {"checked": 0, "ok": 0, "down": 0, "unknown": 0, "dry_run": False}
timeout = httpx.Timeout(connect=float(timeout_seconds), read=float(timeout_seconds), write=float(timeout_seconds), pool=float(timeout_seconds))
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
for row in rows:
link_id = int(row["id"])
status, details = await _evaluate_link(row, client, timeout_seconds)
_persist_status(link_id, status, details)
summary["checked"] += 1
summary[status] += 1
logger.info(
"✅ Links health check completed: checked=%s ok=%s down=%s unknown=%s",
summary["checked"],
summary["ok"],
summary["down"],
summary["unknown"],
)
return summary

View File

@ -121,3 +121,33 @@ class LinkActionResult(BaseModel):
username: Optional[str] = None username: Optional[str] = None
vault_item_id: Optional[str] = None vault_item_id: Optional[str] = None
vault_search_hint: Optional[str] = None vault_search_hint: Optional[str] = None
class LinkLatestStatus(BaseModel):
link_id: int
status: str
checked_at: datetime
details: dict = Field(default_factory=dict)
class VaultCredential(BaseModel):
item_id: Optional[str] = None
item_name: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
totp: Optional[str] = None
notes: Optional[str] = None
url: Optional[str] = None
class LinkVaultResolveRequest(BaseModel):
item_id: Optional[str] = None
search_hint: Optional[str] = None
class LinkVaultResolveResponse(BaseModel):
status: str
configured: bool
message: Optional[str] = None
checked_item_ids: List[str] = Field(default_factory=list)
credential: Optional[VaultCredential] = None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,59 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _render_api_print_bridge(api_path: str, page_title: str) -> str:
safe_api_path = json.dumps(api_path)
safe_title = json.dumps(page_title)
return f"""
<!doctype html>
<html lang=\"da\">
<head>
<meta charset=\"utf-8\" />
<title>{page_title}</title>
<style>
body {{ font-family: 'Segoe UI', sans-serif; margin: 20px; color: #0f172a; }}
.muted {{ color: #475569; }}
.error {{ color: #b42318; }}
</style>
</head>
<body>
<div id=\"state\" class=\"muted\">Henter printvisning...</div>
<script>
(async function () {{
const apiPath = {safe_api_path};
const pageTitle = {safe_title};
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
const headers = token ? {{ Authorization: `Bearer ${{token}}` }} : {{}};
try {{
const res = await fetch(apiPath, {{ method: 'GET', headers, credentials: 'include' }});
if (!res.ok) {{
let detail = `Kunne ikke hente printvisning (${{res.status}})`;
try {{
const payload = await res.json();
if (payload && payload.detail) detail = payload.detail;
}} catch (_) {{}}
document.getElementById('state').className = 'error';
document.getElementById('state').textContent = detail;
return;
}}
const html = await res.text();
document.open();
document.write(html);
document.close();
if (!document.title) document.title = pageTitle;
}} catch (error) {{
document.getElementById('state').className = 'error';
document.getElementById('state').textContent = `Fejl ved hentning af printvisning: ${{error?.message || 'Ukendt fejl'}}`;
}}
}})();
</script>
</body>
</html>
"""
def _is_deadline_overdue(deadline_value) -> bool: def _is_deadline_overdue(deadline_value) -> bool:
if not deadline_value: if not deadline_value:
return False return False
@ -128,7 +181,15 @@ async def sager_liste(
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name, g.name AS assigned_group_name,
nt.title AS next_todo_title, nt.title AS next_todo_title,
nt.due_date AS next_todo_due_date nt.due_date AS next_todo_due_date,
COALESCE(ec.unread_email_count, 0) AS unread_email_count,
ec.oldest_unread_received_date,
CASE
WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none'
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot'
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm'
ELSE 'fresh'
END AS unread_email_level
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
@ -157,6 +218,14 @@ async def sager_liste(
t.created_at ASC t.created_at ASC
LIMIT 1 LIMIT 1
) nt ON true ) nt ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count,
MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE se.sag_id = s.id
) ec ON true
LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id LEFT JOIN sag_sager ds ON ds.id = s.deferred_until_case_id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
""" """
@ -196,10 +265,26 @@ async def sager_liste(
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name, NULL::text AS assigned_group_name,
NULL::text AS next_todo_title, NULL::text AS next_todo_title,
NULL::timestamp AS next_todo_due_date NULL::timestamp AS next_todo_due_date,
COALESCE(ec.unread_email_count, 0) AS unread_email_count,
ec.oldest_unread_received_date,
CASE
WHEN COALESCE(ec.unread_email_count, 0) = 0 THEN 'none'
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '72 hours' THEN 'hot'
WHEN ec.oldest_unread_received_date <= NOW() - INTERVAL '24 hours' THEN 'warm'
ELSE 'fresh'
END AS unread_email_level
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS unread_email_count,
MIN(em.received_date) FILTER (WHERE em.deleted_at IS NULL AND COALESCE(em.is_read, FALSE) = FALSE) AS oldest_unread_received_date
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE se.sag_id = s.id
) ec ON true
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
""" """
fallback_params = [] fallback_params = []
@ -289,6 +374,7 @@ async def sager_liste(
"toggle_include_deferred_url": toggle_include_deferred_url, "toggle_include_deferred_url": toggle_include_deferred_url,
"assignment_users": _fetch_assignment_users(), "assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(), "assignment_groups": _fetch_assignment_groups(),
"current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
}) })
@ -307,6 +393,7 @@ async def sager_liste(
"toggle_include_deferred_url": str(request.url), "toggle_include_deferred_url": str(request.url),
"assignment_users": [], "assignment_users": [],
"assignment_groups": [], "assignment_groups": [],
"current_customer_id": customer_id_int,
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
}) })
@ -320,6 +407,32 @@ async def opret_sag_side(request: Request):
"assignment_groups": _fetch_assignment_groups(), "assignment_groups": _fetch_assignment_groups(),
}) })
@router.get("/sag/{sag_id}/work-orders/print", response_class=HTMLResponse)
async def sag_work_order_print_page(request: Request, sag_id: int):
auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"}
api_path = f"/api/v1/sag/{sag_id}/work-orders/print"
if auto_print:
api_path = f"{api_path}?auto_print=1"
html = _render_api_print_bridge(
api_path=api_path,
page_title=f"Arbejdsseddel SAG-{sag_id}",
)
return HTMLResponse(content=html)
@router.get("/sag/{sag_id}/labels/hardware/print", response_class=HTMLResponse)
async def sag_hardware_labels_print_page(request: Request, sag_id: int):
auto_print = str(request.query_params.get("auto_print", "0")).lower() in {"1", "true", "yes", "on"}
api_path = f"/api/v1/sag/{sag_id}/labels/hardware/print"
if auto_print:
api_path = f"{api_path}?auto_print=1"
html = _render_api_print_bridge(
api_path=api_path,
page_title=f"Hardware labels SAG-{sag_id}",
)
return HTMLResponse(content=html)
@router.get("/sag/varekob-salg", response_class=HTMLResponse) @router.get("/sag/varekob-salg", response_class=HTMLResponse)
async def sag_varekob_salg(request: Request): async def sag_varekob_salg(request: Request):
"""Display orders overview for all purchases and sales.""" """Display orders overview for all purchases and sales."""

View File

@ -124,6 +124,18 @@
[data-bs-theme="dark"] .selected-item button { [data-bs-theme="dark"] .selected-item button {
color: #a6d5fa; color: #a6d5fa;
} }
.case-top-alerts .alert {
border-left: 6px solid;
}
.case-top-alerts .alert-warning {
border-left-color: #f59f00;
}
.case-top-alerts .alert-danger {
border-left-color: #e03131;
}
</style> </style>
{% endblock %} {% endblock %}
@ -139,6 +151,8 @@
</div> </div>
<div class="card-body p-4"> <div class="card-body p-4">
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
<!-- Notifications --> <!-- Notifications -->
<div id="error" class="alert alert-danger d-none shadow-sm" role="alert"> <div id="error" class="alert alert-danger d-none shadow-sm" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span> <i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span>
@ -311,6 +325,79 @@
let contactSearchTimeout; let contactSearchTimeout;
let successAlertTimeout; let successAlertTimeout;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null }; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
let topAlertLoadToken = 0;
function escapeTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function loadCreateTopAlertsForCustomer(customerId) {
const container = document.getElementById('caseTopAlerts');
if (!container) {
return;
}
if (!customerId) {
container.classList.add('d-none');
container.innerHTML = '';
return;
}
const loadToken = ++topAlertLoadToken;
container.classList.remove('d-none');
container.innerHTML = '<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Henter kunde-alerts...</div>';
try {
const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${customerId}`, {
credentials: 'include'
});
if (loadToken !== topAlertLoadToken) {
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const alerts = (data?.alerts || []).filter((alert) => ['critical', 'warning'].includes(String(alert?.severity || '').toLowerCase()));
if (!alerts.length) {
container.classList.add('d-none');
container.innerHTML = '';
return;
}
container.innerHTML = alerts.map((alert) => {
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
const klass = isCritical ? 'alert-danger' : 'alert-warning';
const label = isCritical ? 'KRITISK' : 'ADVARSEL';
const title = escapeTopAlertHtml(alert.title || 'Vigtig kundeinformation');
const message = escapeTopAlertHtml(alert.message || '');
return `
<div class="alert ${klass} mb-2" role="alert">
<strong>${label}:</strong> ${title}
${message ? `<div class="small mt-1">${message}</div>` : ''}
</div>
`;
}).join('');
container.classList.remove('d-none');
} catch (error) {
if (loadToken !== topAlertLoadToken) {
return;
}
console.error('Failed to load customer alerts on sag create:', error);
container.innerHTML = '<div class="alert alert-warning mb-0" role="alert"><strong>Advarsel:</strong> Kunde-alerts kunne ikke hentes.</div>';
container.classList.remove('d-none');
}
}
// Helper function to show success alert // Helper function to show success alert
function showSuccessAlert(message, duration = 3000) { function showSuccessAlert(message, duration = 3000) {
@ -436,6 +523,7 @@
document.getElementById('customerSearch').value = ''; document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').classList.add('d-none'); document.getElementById('customerResults').classList.add('d-none');
renderSelections(); renderSelections();
loadCreateTopAlertsForCustomer(id);
// Show notification // Show notification
if (!skipAlert) { if (!skipAlert) {
@ -447,6 +535,7 @@
selectedCustomer = null; selectedCustomer = null;
document.getElementById('customer_id').value = ''; document.getElementById('customer_id').value = '';
renderSelections(); renderSelections();
loadCreateTopAlertsForCustomer(null);
} }
async function selectContact(id, name) { async function selectContact(id, name) {

View File

@ -1026,6 +1026,78 @@
min-width: 96px; min-width: 96px;
} }
.email-column-shell {
border: 1px solid var(--border-color, #dbe3ea);
border-radius: 14px;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 250, 253, 0.96));
box-shadow: 0 6px 20px rgba(15, 76, 117, 0.06);
}
.email-column-shell .column-header {
background: linear-gradient(90deg, rgba(15, 76, 117, 0.08), rgba(15, 76, 117, 0.02));
}
.email-thread-item .participants-line {
font-size: 0.78rem;
color: var(--text-secondary);
}
.email-thread-item.active .participants-line {
color: rgba(255, 255, 255, 0.82);
}
.mail-read-chip {
font-size: 0.72rem;
letter-spacing: 0.02em;
border-radius: 999px;
}
.email-tab-unread-badge {
display: none;
margin-left: 0.45rem;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.38rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
line-height: 1.35rem;
text-align: center;
background: #2f9e44;
color: #fff;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.92);
}
.email-tab-unread-badge.is-warm {
background: #f08c00;
}
.email-tab-unread-badge.is-hot {
background: #c92a2a;
animation: unreadPulse 1.6s ease-in-out infinite;
}
@keyframes unreadPulse {
0% { transform: scale(1); }
50% { transform: scale(1.09); }
100% { transform: scale(1); }
}
[data-bs-theme="dark"] .email-column-shell {
background: linear-gradient(180deg, rgba(19, 28, 38, 0.96), rgba(17, 24, 33, 0.96));
border-color: rgba(117, 194, 239, 0.2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
[data-bs-theme="dark"] .email-column-shell .column-header {
background: linear-gradient(90deg, rgba(117, 194, 239, 0.16), rgba(117, 194, 239, 0.05));
}
[data-bs-theme="dark"] .email-tab-unread-badge {
box-shadow: 0 0 0 2px rgba(20, 28, 36, 0.95);
}
[data-bs-theme="dark"] .narrative-description { [data-bs-theme="dark"] .narrative-description {
border-color: rgba(117, 194, 239, 0.24); border-color: rgba(117, 194, 239, 0.24);
background: linear-gradient(180deg, rgba(117, 194, 239, 0.14), rgba(117, 194, 239, 0.06)); background: linear-gradient(180deg, rgba(117, 194, 239, 0.14), rgba(117, 194, 239, 0.06));
@ -1494,7 +1566,33 @@
.hardware-list-header, .hardware-list-header,
.hardware-row { .hardware-row {
grid-template-columns: 1.3fr 1fr auto; grid-template-columns: minmax(0, 1.5fr) minmax(110px, 1fr) 56px 56px;
}
.hardware-list-header span:nth-child(3),
.hardware-list-header span:nth-child(4),
.hardware-row > *:nth-child(3),
.hardware-row > *:nth-child(4) {
justify-self: end;
}
.hardware-row > div:first-child {
min-width: 0;
}
.hardware-row > div:first-child a {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hardware-row small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.location-list-header, .location-list-header,
@ -2220,6 +2318,14 @@
<i class="bi bi-plus-circle"></i> Registrer session <i class="bi bi-plus-circle"></i> Registrer session
</button> </button>
</div> </div>
<div class="case-tabs-topbar-item field-documents">
<div class="case-tabs-topbar-label"><i class="bi bi-printer"></i>Arbejdsdokumenter</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="topbar-secondary-action is-wide" onclick="openCaseWorkOrderPrint()" title="Print arbejdsseddel">
<i class="bi bi-file-earmark-text"></i> Print arbejdsseddel
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -2232,6 +2338,8 @@
{% set ticon = type_icons.get(tkey, 'bi-card-text') %} {% set ticon = type_icons.get(tkey, 'bi-card-text') %}
{% set tlabel = type_labels.get(tkey, tkey|capitalize) %} {% set tlabel = type_labels.get(tkey, tkey|capitalize) %}
<div id="caseCustomerTopAlerts" class="mb-3 d-none"></div>
<!-- ═══════════════ PREMIUM CASE HEADER ═══════════════ --> <!-- ═══════════════ PREMIUM CASE HEADER ═══════════════ -->
<div class="case-hero mb-4"> <div class="case-hero mb-4">
@ -2407,6 +2515,7 @@
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="emails-tab" data-bs-toggle="tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails" onclick="forceCaseTabActivation('emails', this)"> <button class="nav-link" id="emails-tab" data-bs-toggle="tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails" onclick="forceCaseTabActivation('emails', this)">
<i class="bi bi-envelope me-2"></i>E-mail <i class="bi bi-envelope me-2"></i>E-mail
<span id="emailTabUnreadBadge" class="email-tab-unread-badge" aria-label="Ulæste emails"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -2820,9 +2929,6 @@
<span id="previewFileName">Fil preview</span> <span id="previewFileName">Fil preview</span>
</h5> </h5>
<div class="ms-auto d-flex align-items-center gap-2"> <div class="ms-auto d-flex align-items-center gap-2">
<a id="previewDownloadBtn" href="#" class="btn btn-sm btn-outline-primary" download>
<i class="bi bi-download me-1"></i> Download
</a>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
</div> </div>
@ -3183,6 +3289,7 @@
<script> <script>
const caseId = {{ case.id }}; const caseId = {{ case.id }};
const caseCustomerId = {{ case.customer_id if case.customer_id else 'null' }};
const wikiCustomerId = {{ customer.id if customer else 'null' }}; const wikiCustomerId = {{ customer.id if customer else 'null' }};
const wikiDefaultTag = "guide"; const wikiDefaultTag = "guide";
let contactSearchTimeout; let contactSearchTimeout;
@ -3192,6 +3299,67 @@
let selectedRelationCaseId = null; let selectedRelationCaseId = null;
const caseTypeKey = "{{ (case.template_key or case.type or 'ticket')|lower }}"; const caseTypeKey = "{{ (case.template_key or case.type or 'ticket')|lower }}";
function escapeCaseTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function loadCaseCustomerTopAlerts() {
const container = document.getElementById('caseCustomerTopAlerts');
if (!container || !caseCustomerId) {
return;
}
container.classList.remove('d-none');
container.innerHTML = '<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Henter kunde-alerts...</div>';
try {
const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${caseCustomerId}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
const alerts = (payload?.alerts || []).filter((alert) => {
const severity = String(alert?.severity || '').toLowerCase();
return severity === 'critical' || severity === 'warning';
});
if (!alerts.length) {
container.classList.add('d-none');
container.innerHTML = '';
return;
}
container.innerHTML = alerts.map((alert) => {
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
const level = isCritical ? 'KRITISK' : 'ADVARSEL';
const klass = isCritical ? 'alert-danger' : 'alert-warning';
const title = escapeCaseTopAlertHtml(alert.title || 'Vigtig kundeinformation');
const message = escapeCaseTopAlertHtml(alert.message || '');
return `
<div class="alert ${klass} mb-2" role="alert" style="border-left: 6px solid ${isCritical ? '#e03131' : '#f59f00'};">
<strong>${level}:</strong> ${title}
${message ? `<div class="small mt-1">${message}</div>` : ''}
</div>
`;
}).join('');
container.classList.remove('d-none');
} catch (error) {
console.error('Failed to load case customer alerts:', error);
container.innerHTML = '<div class="alert alert-warning mb-0" role="alert"><strong>Advarsel:</strong> Kunde-alerts kunne ikke hentes.</div>';
container.classList.remove('d-none');
}
}
function forceCaseTabActivation(tabId) { function forceCaseTabActivation(tabId) {
if (!tabId) return; if (!tabId) return;
@ -3243,6 +3411,7 @@
// Initialize everything when DOM is ready // Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
hydrateTopbarStatusOptions(); hydrateTopbarStatusOptions();
loadCaseCustomerTopAlerts();
// Initialize modals // Initialize modals
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal')); contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal')); customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
@ -3362,6 +3531,110 @@
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300); setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
} }
async function openAuthenticatedPrintView(url, fallbackTitle) {
const popup = window.open('', '_blank', 'noopener');
if (!popup) {
alert('Browser blokerede popup-vinduet. Tillad popups for at printe.');
return;
}
popup.document.write('<!doctype html><html><head><meta charset="utf-8"><title>Loader...</title></head><body style="font-family:Segoe UI,sans-serif;padding:20px;">Henter printvisning...</body></html>');
popup.document.close();
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
try {
const res = await fetch(url, {
method: 'GET',
headers,
credentials: 'include'
});
if (!res.ok) {
let detail = `Kunne ikke hente printvisning (${res.status})`;
try {
const payload = await res.json();
if (payload?.detail) detail = payload.detail;
} catch (_) {
}
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:#b42318;">${escapeHtml(detail)}</div>`;
return;
}
const htmlContent = await res.text();
popup.document.open();
popup.document.write(htmlContent);
popup.document.close();
if (fallbackTitle && !popup.document.title) {
popup.document.title = fallbackTitle;
}
} catch (error) {
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:#b42318;">Fejl ved hentning af printvisning: ${escapeHtml(error?.message || 'Ukendt fejl')}</div>`;
}
}
function openCaseWorkOrderPrint() {
window.open(`/sag/${caseId}/work-orders/print`, '_blank', 'noopener');
}
function openCaseHardwareLabelsPrint() {
window.open(`/sag/${caseId}/labels/hardware/print`, '_blank', 'noopener');
}
function openCaseHardwareLabelsPrintDirect() {
window.open(`/sag/${caseId}/labels/hardware/print?auto_print=1`, '_blank', 'noopener');
}
async function sendCaseHardwareLabelsToPrinter(hardwareId = null) {
try {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const requestPayload = {};
if (hardwareId !== null && hardwareId !== undefined) {
const parsedHardwareId = Number(hardwareId);
if (!Number.isFinite(parsedHardwareId)) {
showNotification('Ugyldigt hardware-id', 'error');
return;
}
requestPayload.hardware_id = parsedHardwareId;
}
const response = await fetch(`/api/v1/sag/${caseId}/labels/hardware/print-direct`, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(requestPayload)
});
if (!response.ok) {
let detail = `Print fejlede (${response.status})`;
try {
const payload = await response.json();
if (payload && payload.detail) detail = payload.detail;
} catch (_) {}
showNotification(detail, 'error');
// Fallback: open browser print view if direct printer is unavailable.
openCaseHardwareLabelsPrintDirect();
return;
}
const payload = await response.json().catch(() => ({}));
const printed = payload?.printed || 0;
if (hardwareId !== null && hardwareId !== undefined) {
showNotification('Label sendt til printer', 'success');
} else {
showNotification(`Sendte ${printed} labels til printer`, 'success');
}
} catch (error) {
showNotification(`Print fejlede: ${error?.message || 'ukendt fejl'}`, 'error');
}
}
function showContactInfoModal(el) { function showContactInfoModal(el) {
currentContactInfo = { currentContactInfo = {
id: el.dataset.contactId, id: el.dataset.contactId,
@ -4047,6 +4320,7 @@
<div class="hardware-list-header"> <div class="hardware-list-header">
<span>Enhed</span> <span>Enhed</span>
<span>SN</span> <span>SN</span>
<span>Label</span>
<span>Slet</span> <span>Slet</span>
</div> </div>
${hardware.map(h => ` ${hardware.map(h => `
@ -4057,6 +4331,9 @@
</a> </a>
</div> </div>
<small>${h.serial_number || '-'}</small> <small>${h.serial_number || '-'}</small>
<button class="btn btn-sm btn-outline-primary" onclick="sendCaseHardwareLabelsToPrinter(${h.id})" title="Print label for denne hardware">
<i class="bi bi-printer"></i>
</button>
<button class="btn btn-sm btn-delete" onclick="unlinkHardware(${h.id})" title="Slet"> <button class="btn btn-sm btn-delete" onclick="unlinkHardware(${h.id})" title="Slet">
</button> </button>
@ -4939,10 +5216,15 @@
<div class="card d-flex flex-column h-100 right-module-card" data-module="hardware" data-has-content="unknown"> <div class="card d-flex flex-column h-100 right-module-card" data-module="hardware" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">💻 Hardware</h6> <h6 class="mb-0" style="color: var(--accent);">💻 Hardware</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('hardware')"> <div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-outline-secondary" onclick="sendCaseHardwareLabelsToPrinter()" title="Print labels for alt hardware på sagen">
<i class="bi bi-printer"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('hardware')" title="Tilknyt hardware">
<i class="bi bi-link-45deg"></i> <i class="bi bi-link-45deg"></i>
</button> </button>
</div> </div>
</div>
<div class="card-body flex-grow-1 overflow-auto p-0" style="max-height: 180px;"> <div class="card-body flex-grow-1 overflow-auto p-0" style="max-height: 180px;">
<div class="list-group list-group-flush" id="hardware-list"> <div class="list-group list-group-flush" id="hardware-list">
<div class="p-3 text-center text-muted">Henter hardware...</div> <div class="p-3 text-center text-muted">Henter hardware...</div>
@ -5142,7 +5424,7 @@
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-envelope me-2"></i>E-mail på sagen</h6> <h6 class="mb-0 text-primary"><i class="bi bi-envelope me-2"></i>E-mail på sagen</h6>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#caseEmailComposeModal"> <button class="btn btn-sm btn-primary" type="button" onclick="startNewEmailThread()">
<i class="bi bi-envelope-plus me-1"></i>Ny email <i class="bi bi-envelope-plus me-1"></i>Ny email
</button> </button>
<input type="file" id="emailImportInput" accept=".eml,.msg" style="display:none" onchange="if(this.files?.length){ uploadEmailFile(this.files[0]); this.value=''; }"> <input type="file" id="emailImportInput" accept=".eml,.msg" style="display:none" onchange="if(this.files?.length){ uploadEmailFile(this.files[0]); this.value=''; }">
@ -5181,8 +5463,8 @@
</div> </div>
<div class="col-lg-3 col-xl-2"> <div class="col-lg-3 col-xl-2">
<div class="border rounded h-100 d-flex flex-column"> <div class="email-column-shell h-100 d-flex flex-column">
<div class="p-2 border-bottom d-flex justify-content-between align-items-center"> <div class="column-header p-2 border-bottom d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-secondary">Mail tråd</span> <span class="small fw-semibold text-secondary">Mail tråd</span>
<span class="badge bg-light text-dark border" id="linkedEmailThreadsCount">0</span> <span class="badge bg-light text-dark border" id="linkedEmailThreadsCount">0</span>
</div> </div>
@ -5193,8 +5475,8 @@
</div> </div>
<div class="col-lg-3 col-xl-3"> <div class="col-lg-3 col-xl-3">
<div class="border rounded h-100 d-flex flex-column"> <div class="email-column-shell h-100 d-flex flex-column">
<div class="p-2 border-bottom d-flex justify-content-between align-items-center"> <div class="column-header p-2 border-bottom d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-secondary">Mails i tråden</span> <span class="small fw-semibold text-secondary">Mails i tråden</span>
<span class="badge bg-light text-dark border" id="threadEmailsCount">0</span> <span class="badge bg-light text-dark border" id="threadEmailsCount">0</span>
</div> </div>
@ -5205,7 +5487,7 @@
</div> </div>
<div class="col-lg-6 col-xl-7"> <div class="col-lg-6 col-xl-7">
<div class="border rounded h-100 d-flex flex-column" id="email-preview-panel" style="min-height: 420px;"> <div class="email-column-shell h-100 d-flex flex-column" id="email-preview-panel" style="min-height: 420px;">
<div class="p-3 text-center text-muted d-flex align-items-center justify-content-center flex-grow-1"> <div class="p-3 text-center text-muted d-flex align-items-center justify-content-center flex-grow-1">
Vælg en e-mail i listen for at se indhold og vedhæftninger Vælg en e-mail i listen for at se indhold og vedhæftninger
</div> </div>
@ -10071,13 +10353,24 @@
setModuleContentState('files', true); setModuleContentState('files', true);
container.innerHTML = files.map(f => { container.innerHTML = files.map(f => {
const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB'; const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB';
const sourceType = String(f.source_type || '').toLowerCase();
const sourceToken = String(f.source_token || '').toUpperCase();
const isWorkOrder = sourceType === 'scanner_email' && sourceToken.includes('BMCSCAN-WO-');
const isScannerFile = sourceType === 'scanner_email';
const displayName = isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename;
const badgeHtml = isWorkOrder
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
: (isScannerFile
? '<span class="badge rounded-pill text-bg-info ms-2"><i class="bi bi-upc-scan me-1"></i>Scanner</span>'
: '');
return ` return `
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item d-flex justify-content-between align-items-center">
<div class="ms-2 me-auto"> <div class="ms-2 me-auto">
<div class="fw-bold text-truncate" style="max-width: 250px;"> <div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;">
<a href="javascript:void(0);" onclick="previewFile(${f.id}, '${f.filename.replace(/'/g, "\\'")}', '${f.content_type || ''}')" class="text-decoration-none text-dark"> <a href="javascript:void(0);" onclick="previewFile(${f.id}, '${f.filename.replace(/'/g, "\\'")}', '${f.content_type || ''}')" class="text-decoration-none text-dark">
<i class="bi bi-file-earmark me-1"></i> ${f.filename} <i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName}
</a> </a>
${badgeHtml}
</div> </div>
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small> <small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
</div> </div>
@ -10130,18 +10423,60 @@
} catch(e) { alert("Fejl: " + e); } } catch(e) { alert("Fejl: " + e); }
} }
async function renderPdfPreview(fileUrl, fileId, filename, previewContent) {
const imageUrl = `/api/v1/sag/${caseIds}/files/${fileId}/preview-image?page=1&scale=3.4`;
previewContent.innerHTML = `
<div class="d-flex flex-column align-items-center justify-content-center w-100" style="min-height: 70vh;">
<div class="spinner-border text-primary" role="status"></div>
<div class="small text-muted mt-2">Indlæser PDF...</div>
</div>
`;
const img = new Image();
img.alt = filename;
img.className = 'img-fluid';
img.style.maxHeight = '86vh';
img.style.width = 'auto';
img.style.display = 'block';
img.style.margin = '0 auto';
img.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)';
img.style.background = '#fff';
img.onload = () => {
previewContent.innerHTML = '';
const frame = document.createElement('div');
frame.style.background = '#eef2f7';
frame.style.padding = '6px';
frame.style.borderRadius = '12px';
frame.style.minHeight = '82vh';
frame.appendChild(img);
previewContent.appendChild(frame);
};
img.onerror = () => {
// Fallback to native viewer in full-window tab if image rendering fails.
window.open(fileUrl, '_blank', 'noopener');
previewContent.innerHTML = `
<div class="p-4 text-center text-muted">
Kunne ikke vise PDF i modal. Åbnede i nyt vindue.
</div>
`;
};
img.src = imageUrl;
}
// File Preview // File Preview
function previewFile(fileId, filename, contentType) { function previewFile(fileId, filename, contentType) {
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal')); const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
const previewContent = document.getElementById('previewContent'); const previewContent = document.getElementById('previewContent');
const fileNameEl = document.getElementById('previewFileName'); const fileNameEl = document.getElementById('previewFileName');
const downloadBtn = document.getElementById('previewDownloadBtn');
// Set filename and download link // Set filename
fileNameEl.textContent = filename; fileNameEl.textContent = filename;
const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`; const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`;
downloadBtn.href = `${fileUrl}?download=true`;
downloadBtn.download = filename;
// Show loading spinner // Show loading spinner
previewContent.innerHTML = ` previewContent.innerHTML = `
@ -10159,8 +10494,8 @@
// Image preview // Image preview
previewContent.innerHTML = `<img src="${fileUrl}" class="img-fluid" style="max-height: 80vh;" alt="${filename}">`; previewContent.innerHTML = `<img src="${fileUrl}" class="img-fluid" style="max-height: 80vh;" alt="${filename}">`;
} else if (ext === 'pdf') { } else if (ext === 'pdf') {
// PDF preview using iframe // PDF preview with forced readable scale
previewContent.innerHTML = `<iframe src="${fileUrl}" class="w-100 h-100 border-0" style="min-height: 60vh;"></iframe>`; renderPdfPreview(fileUrl, fileId, filename, previewContent);
} else if (['txt', 'log', 'md', 'json', 'xml', 'csv', 'html', 'css', 'js', 'py', 'sql'].includes(ext)) { } else if (['txt', 'log', 'md', 'json', 'xml', 'csv', 'html', 'css', 'js', 'py', 'sql'].includes(ext)) {
// Text file preview // Text file preview
fetch(fileUrl) fetch(fileUrl)
@ -10233,6 +10568,7 @@
let linkedEmailsCache = []; let linkedEmailsCache = [];
let filteredLinkedEmailsCache = []; let filteredLinkedEmailsCache = [];
let selectedLinkedEmailId = null; let selectedLinkedEmailId = null;
let isNewThread = false; // true when composing a brand-new thread (not a reply)
let selectedLinkedEmailDetail = null; let selectedLinkedEmailDetail = null;
let selectedEmailThreadKey = null; let selectedEmailThreadKey = null;
@ -10472,6 +10808,14 @@
function prefillCaseEmailCompose() { function prefillCaseEmailCompose() {
const toInput = document.getElementById('caseEmailTo'); const toInput = document.getElementById('caseEmailTo');
const subjectInput = document.getElementById('caseEmailSubject'); const subjectInput = document.getElementById('caseEmailSubject');
const modalLabel = document.getElementById('caseEmailComposeModalLabel');
// Update modal title based on context
if (modalLabel) {
modalLabel.innerHTML = isNewThread
? '<i class="bi bi-envelope-plus me-2"></i>Ny email (ny tråd)'
: '<i class="bi bi-envelope me-2"></i>Ny email';
}
if (toInput && !toInput.value.trim()) { if (toInput && !toInput.value.trim()) {
const recipient = getDefaultCaseRecipient(); const recipient = getDefaultCaseRecipient();
@ -10481,7 +10825,28 @@
} }
if (subjectInput && !subjectInput.value.trim()) { if (subjectInput && !subjectInput.value.trim()) {
subjectInput.value = escapeHtmlForInput(`Sag #${caseIds}: `); const title = (currentCaseTitle || '').trim() || 'EMNE PÅ SAGEN';
subjectInput.value = escapeHtmlForInput(`(Sag:${caseIds}) - "${title}"`);
}
}
function startNewEmailThread() {
// Clear all compose fields and thread references
selectedLinkedEmailId = null;
isNewThread = true;
const toInput = document.getElementById('caseEmailTo');
const ccInput = document.getElementById('caseEmailCc');
const bccInput = document.getElementById('caseEmailBcc');
const subjectInput = document.getElementById('caseEmailSubject');
const bodyInput = document.getElementById('caseEmailBody');
if (toInput) toInput.value = '';
if (ccInput) ccInput.value = '';
if (bccInput) bccInput.value = '';
if (subjectInput) subjectInput.value = '';
if (bodyInput) bodyInput.value = '';
const composeModalEl = document.getElementById('caseEmailComposeModal');
if (composeModalEl) {
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
} }
} }
@ -10490,6 +10855,7 @@
if (!composeModalEl || !selectedLinkedEmailId || !selectedLinkedEmailDetail) { if (!composeModalEl || !selectedLinkedEmailId || !selectedLinkedEmailDetail) {
return; return;
} }
isNewThread = false; // This is a reply, not a new thread
const toInput = document.getElementById('caseEmailTo'); const toInput = document.getElementById('caseEmailTo');
const subjectInput = document.getElementById('caseEmailSubject'); const subjectInput = document.getElementById('caseEmailSubject');
@ -10573,8 +10939,8 @@
subject, subject,
body_text: bodyText, body_text: bodyText,
attachment_file_ids: attachmentFileIds, attachment_file_ids: attachmentFileIds,
thread_email_id: selectedLinkedEmailId || null, thread_email_id: isNewThread ? null : (selectedLinkedEmailId || null),
thread_key: ( thread_key: isNewThread ? null : (
linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.resolved_thread_key linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.resolved_thread_key
|| linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key || linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key
|| null || null
@ -10615,6 +10981,7 @@
statusEl.className = 'text-success'; statusEl.className = 'text-success';
statusEl.textContent = 'E-mail sendt.'; statusEl.textContent = 'E-mail sendt.';
isNewThread = false; // Reset after send
loadLinkedEmails(); loadLinkedEmails();
const composeModalEl = document.getElementById('caseEmailComposeModal'); const composeModalEl = document.getElementById('caseEmailComposeModal');
@ -10661,20 +11028,56 @@
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`); const res = await fetch(`/api/v1/sag/${caseIds}/email-links`);
if(res.ok) { if(res.ok) {
linkedEmailsCache = await res.json(); linkedEmailsCache = await res.json();
updateEmailTabUnreadBadge();
await applyLinkedEmailFilters(true); await applyLinkedEmailFilters(true);
} else { } else {
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>'; container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>'; threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>';
updateEmailTabUnreadBadge();
setModuleContentState('emails', true); setModuleContentState('emails', true);
} }
} catch(e) { } catch(e) {
console.error(e); console.error(e);
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>'; container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>'; threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>';
updateEmailTabUnreadBadge();
setModuleContentState('emails', true); setModuleContentState('emails', true);
} }
} }
function updateEmailTabUnreadBadge() {
const badge = document.getElementById('emailTabUnreadBadge');
if (!badge) return;
const unreadItems = (linkedEmailsCache || []).filter((mail) => !Boolean(mail?.is_read));
if (!unreadItems.length) {
badge.style.display = 'none';
badge.textContent = '';
badge.classList.remove('is-warm', 'is-hot');
return;
}
const now = Date.now();
const oldestUnreadMs = unreadItems.reduce((oldest, item) => {
const ts = item?.received_date ? new Date(item.received_date).getTime() : now;
return Math.min(oldest, Number.isFinite(ts) ? ts : now);
}, now);
const ageHours = Math.max(0, (now - oldestUnreadMs) / (1000 * 60 * 60));
badge.classList.remove('is-warm', 'is-hot');
if (ageHours >= 72) {
badge.classList.add('is-hot');
} else if (ageHours >= 24) {
badge.classList.add('is-warm');
}
badge.style.display = 'inline-block';
badge.textContent = unreadItems.length > 99 ? '99+' : String(unreadItems.length);
badge.title = ageHours >= 72
? 'Ulæste mails (ældre end 72 timer)'
: (ageHours >= 24 ? 'Ulæste mails (ældre end 24 timer)' : 'Ulæste mails');
}
function getFilteredLinkedEmails() { function getFilteredLinkedEmails() {
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase(); const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all'; const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
@ -10821,6 +11224,38 @@
const counter = document.getElementById('linkedEmailThreadsCount'); const counter = document.getElementById('linkedEmailThreadsCount');
if (counter) counter.textContent = String(threadGroups.length); if (counter) counter.textContent = String(threadGroups.length);
const getThreadParticipants = (group) => {
const participants = [];
const seen = new Set();
(group.emails || []).forEach((mail) => {
const sender = (mail.sender_name || mail.sender_email || '').trim();
if (sender && !seen.has(sender.toLowerCase())) {
participants.push(sender);
seen.add(sender.toLowerCase());
}
if (isOutgoingEmail(mail)) {
const recipients = String(mail.recipient_email || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
recipients.forEach((recipient) => {
const key = recipient.toLowerCase();
if (!seen.has(key)) {
participants.push(recipient);
seen.add(key);
}
});
}
});
if (!participants.length) return 'Deltagere: -';
const visible = participants.slice(0, 3);
const extra = participants.length - visible.length;
return `Deltagere: ${visible.join(', ')}${extra > 0 ? ` +${extra}` : ''}`;
};
container.innerHTML = threadGroups.map((group) => { container.innerHTML = threadGroups.map((group) => {
const latest = group.latestEmail || {}; const latest = group.latestEmail || {};
const isSelected = selectedEmailThreadKey === group.threadKey; const isSelected = selectedEmailThreadKey === group.threadKey;
@ -10828,13 +11263,15 @@
const sender = latest.sender_name || latest.sender_email || '-'; const sender = latest.sender_name || latest.sender_email || '-';
const subject = latest.subject || '(Ingen emne)'; const subject = latest.subject || '(Ingen emne)';
const unreadCount = group.emails.filter((item) => !item.is_read).length; const unreadCount = group.emails.filter((item) => !item.is_read).length;
const participantsLabel = getThreadParticipants(group);
return ` return `
<button type="button" class="list-group-item list-group-item-action border-0 border-bottom text-start ${isSelected ? 'active' : ''}" onclick='selectEmailThread(${JSON.stringify(group.threadKey)})'> <button type="button" class="email-thread-item list-group-item list-group-item-action border-0 border-bottom text-start ${isSelected ? 'active' : ''}" onclick='selectEmailThread(${JSON.stringify(group.threadKey)})'>
<div class="d-flex justify-content-between align-items-start gap-2"> <div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<div class="fw-semibold text-truncate">${escapeHtml(subject)}</div> <div class="fw-semibold text-truncate">${escapeHtml(subject)}</div>
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(sender)}</div> <div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(sender)}</div>
<div class="participants-line text-truncate">${escapeHtml(participantsLabel)}</div>
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(receivedDate)}</div> <div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(receivedDate)}</div>
</div> </div>
<div class="d-flex flex-column align-items-end gap-1"> <div class="d-flex flex-column align-items-end gap-1">
@ -10872,6 +11309,7 @@
async function applyLinkedEmailFilters(loadDetail = false) { async function applyLinkedEmailFilters(loadDetail = false) {
filteredLinkedEmailsCache = getFilteredLinkedEmails(); filteredLinkedEmailsCache = getFilteredLinkedEmails();
updateEmailTabUnreadBadge();
const threadGroups = buildThreadGroups(filteredLinkedEmailsCache); const threadGroups = buildThreadGroups(filteredLinkedEmailsCache);
renderEmailThreads(threadGroups); renderEmailThreads(threadGroups);
@ -11013,6 +11451,13 @@
<button type="button" class="btn btn-sm btn-primary" onclick="openReplyToLinkedEmail()"> <button type="button" class="btn btn-sm btn-primary" onclick="openReplyToLinkedEmail()">
<i class="bi bi-reply me-1"></i>Svar i tråd <i class="bi bi-reply me-1"></i>Svar i tråd
</button> </button>
<button type="button" class="btn btn-sm ${email.is_read ? 'btn-outline-secondary' : 'btn-warning'}" id="emailReadToggleBtn" onclick="toggleLinkedEmailReadState()">
<i class="bi ${email.is_read ? 'bi-envelope-open' : 'bi-envelope'} me-1"></i>
${email.is_read ? 'Marker som ulæst' : 'Marker som læst'}
</button>
<span class="badge ${email.is_read ? 'bg-success-subtle text-success-emphasis' : 'bg-warning text-dark'} mail-read-chip align-self-center" id="emailReadStateBadge">
${email.is_read ? 'Læst' : 'Ulæst'}
</span>
</div> </div>
</div> </div>
<div class="p-3 border-bottom"> <div class="p-3 border-bottom">
@ -11039,14 +11484,16 @@
const cacheIdx = linkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id)); const cacheIdx = linkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id));
if (cacheIdx >= 0) { if (cacheIdx >= 0) {
linkedEmailsCache[cacheIdx].is_read = true; linkedEmailsCache[cacheIdx].is_read = Boolean(email.is_read);
} }
const filteredIdx = filteredLinkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id)); const filteredIdx = filteredLinkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id));
if (filteredIdx >= 0) { if (filteredIdx >= 0) {
filteredLinkedEmailsCache[filteredIdx].is_read = true; filteredLinkedEmailsCache[filteredIdx].is_read = Boolean(email.is_read);
} }
updateEmailTabUnreadBadge();
if (!skipRefresh) { if (!skipRefresh) {
const threadEmails = getCurrentThreadEmails(); const threadEmails = getCurrentThreadEmails();
renderLinkedEmails(threadEmails); renderLinkedEmails(threadEmails);
@ -11059,6 +11506,62 @@
} }
} }
async function toggleLinkedEmailReadState() {
if (!selectedLinkedEmailDetail || !selectedLinkedEmailId) return;
const targetState = !Boolean(selectedLinkedEmailDetail.is_read);
try {
const res = await fetch(`/api/v1/emails/${selectedLinkedEmailId}/read-state`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_read: targetState })
});
if (!res.ok) {
let detail = `Kunne ikke opdatere læsestatus (${res.status})`;
try {
const payload = await res.json();
if (payload?.detail) detail = payload.detail;
} catch (_) {
}
alert(detail);
return;
}
selectedLinkedEmailDetail.is_read = targetState;
const updateCollection = (items) => {
const idx = items.findIndex((entry) => Number(entry.id) === Number(selectedLinkedEmailId));
if (idx >= 0) {
items[idx].is_read = targetState;
}
};
updateCollection(linkedEmailsCache);
updateCollection(filteredLinkedEmailsCache);
updateEmailTabUnreadBadge();
const threadEmails = getCurrentThreadEmails();
renderLinkedEmails(threadEmails);
renderEmailThreads(buildThreadGroups(filteredLinkedEmailsCache));
const toggleBtn = document.getElementById('emailReadToggleBtn');
if (toggleBtn) {
toggleBtn.className = `btn btn-sm ${targetState ? 'btn-outline-secondary' : 'btn-warning'}`;
toggleBtn.innerHTML = `<i class="bi ${targetState ? 'bi-envelope-open' : 'bi-envelope'} me-1"></i>${targetState ? 'Marker som ulæst' : 'Marker som læst'}`;
}
const badge = document.getElementById('emailReadStateBadge');
if (badge) {
badge.className = `badge ${targetState ? 'bg-success-subtle text-success-emphasis' : 'bg-warning text-dark'} mail-read-chip align-self-center`;
badge.textContent = targetState ? 'Læst' : 'Ulæst';
}
} catch (error) {
console.error(error);
alert('Fejl ved opdatering af læsestatus.');
}
}
async function unlinkEmail(emailId) { async function unlinkEmail(emailId) {
if(!confirm("Fjern link til denne email?")) return; if(!confirm("Fjern link til denne email?")) return;
try { try {

View File

@ -86,6 +86,41 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.sag-unread-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.35rem;
margin-left: 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
color: #fff;
vertical-align: middle;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
}
.sag-unread-fresh {
background: #2f9e44;
}
.sag-unread-warm {
background: #f08c00;
}
.sag-unread-hot {
background: #c92a2a;
animation: sagUnreadPulse 1.8s ease-in-out infinite;
}
@keyframes sagUnreadPulse {
0% { transform: scale(1); }
50% { transform: scale(1.08); }
100% { transform: scale(1); }
}
.sag-titel { .sag-titel {
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
@ -266,11 +301,30 @@
margin-bottom: 1rem; margin-bottom: 1rem;
opacity: 0.3; opacity: 0.3;
} }
.sag-top-alerts {
margin-bottom: 1rem;
}
.sag-top-alerts .alert {
border-left: 6px solid;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.sag-top-alerts .alert-warning {
border-left-color: #f59f00;
}
.sag-top-alerts .alert-danger {
border-left-color: #e03131;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid" style="max-width: none; padding-top: 2rem;"> <div class="container-fluid" style="max-width: none; padding-top: 2rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<!-- Header --> <!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="margin: 0; color: var(--accent);"> <h1 style="margin: 0; color: var(--accent);">
@ -382,6 +436,12 @@
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span> <span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %} {% endif %}
<span class="sag-id">#{{ sag.id }}</span> <span class="sag-id">#{{ sag.id }}</span>
{% if (sag.unread_email_count or 0) > 0 %}
{% set unread_level = sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
{{ sag.unread_email_count if sag.unread_email_count <= 99 else '99+' }}
</span>
{% endif %}
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }} {{ sag.customer_name if sag.customer_name else '-' }}
@ -440,6 +500,12 @@
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;"> <tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;">
<td> <td>
<span class="sag-id">#{{ related_sag.id }}</span> <span class="sag-id">#{{ related_sag.id }}</span>
{% if (related_sag.unread_email_count or 0) > 0 %}
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
{{ related_sag.unread_email_count if related_sag.unread_email_count <= 99 else '99+' }}
</span>
{% endif %}
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;"> <td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }} {{ related_sag.customer_name if related_sag.customer_name else '-' }}
@ -508,6 +574,67 @@
</div> </div>
<script> <script>
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
function escapeTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function loadSagTopAlertsForCustomer(customerId) {
const container = document.getElementById('sagTopAlerts');
if (!container || !customerId) {
return;
}
container.classList.remove('d-none');
container.innerHTML = '<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Henter kunde-alerts...</div>';
try {
const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${customerId}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const alerts = (data?.alerts || []).filter((alert) => ['critical', 'warning'].includes(String(alert?.severity || '').toLowerCase()));
if (!alerts.length) {
container.classList.add('d-none');
container.innerHTML = '';
return;
}
const html = alerts.map((alert) => {
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
const klass = isCritical ? 'alert-danger' : 'alert-warning';
const label = isCritical ? 'KRITISK' : 'ADVARSEL';
const title = escapeTopAlertHtml(alert.title || 'Vigtig kundeinformation');
const message = escapeTopAlertHtml(alert.message || '');
return `
<div class="alert ${klass} mb-2" role="alert">
<strong>${label}:</strong> ${title}
${message ? `<div class="small mt-1">${message}</div>` : ''}
</div>
`;
}).join('');
container.innerHTML = html;
container.classList.remove('d-none');
} catch (error) {
console.error('Failed to load customer alerts on sag list:', error);
container.innerHTML = '<div class="alert alert-warning mb-0" role="alert"><strong>Advarsel:</strong> Kunde-alerts kunne ikke hentes.</div>';
container.classList.remove('d-none');
}
}
// Tree toggle functionality // Tree toggle functionality
function toggleTreeNode(event, sagId) { function toggleTreeNode(event, sagId) {
event.stopPropagation(); event.stopPropagation();
@ -636,5 +763,9 @@
} }
loadTypeFilters(); loadTypeFilters();
if (topAlertCustomerId) {
loadSagTopAlertsForCustomer(topAlertCustomerId);
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,263 @@
"""Brother QL direct print service for case hardware labels."""
from __future__ import annotations
import logging
import socket
from dataclasses import dataclass
from typing import Iterable, List, Optional
from PIL import Image, ImageDraw, ImageFont
# Compatibility shim: brother_ql may still reference Image.ANTIALIAS,
# which was removed in newer Pillow releases.
if not hasattr(Image, "ANTIALIAS") and hasattr(Image, "Resampling"):
Image.ANTIALIAS = Image.Resampling.LANCZOS
logger = logging.getLogger(__name__)
try:
from brother_ql.backends.helpers import send
from brother_ql.conversion import convert
from brother_ql.raster import BrotherQLRaster
from brother_ql.labels import ALL_LABELS
except Exception: # pragma: no cover - handled at runtime
send = None
convert = None
BrotherQLRaster = None
ALL_LABELS = None
_CODE39_PATTERNS = {
"0": "nnnwwnwnn", "1": "wnnwnnnnw", "2": "nnwwnnnnw", "3": "wnwwnnnnn",
"4": "nnnwwnnnw", "5": "wnnwwnnnn", "6": "nnwwwnnnn", "7": "nnnwnnwnw",
"8": "wnnwnnwnn", "9": "nnwwnnwnn", "A": "wnnnnwnnw", "B": "nnwnnwnnw",
"C": "wnwnnwnnn", "D": "nnnnwwnnw", "E": "wnnnwwnnn", "F": "nnwnwwnnn",
"G": "nnnnnwwnw", "H": "wnnnnwwnn", "I": "nnwnnwwnn", "J": "nnnnwwwnn",
"K": "wnnnnnnww", "L": "nnwnnnnww", "M": "wnwnnnnwn", "N": "nnnnwnnww",
"O": "wnnnwnnwn", "P": "nnwnwnnwn", "Q": "nnnnnnwww", "R": "wnnnnnwwn",
"S": "nnwnnnwwn", "T": "nnnnwnwwn", "U": "wwnnnnnnw", "V": "nwwnnnnnw",
"W": "wwwnnnnnn", "X": "nwnnwnnnw", "Y": "wwnnwnnnn", "Z": "nwwnwnnnn",
"-": "nwnnnnwnw", ".": "wwnnnnwnn", " ": "nwwnnnwnn", "$": "nwnwnwnnn",
"/": "nwnwnnnwn", "+": "nwnnnwnwn", "%": "nnnwnwnwn", "*": "nwnnwnwnn",
}
@dataclass
class LabelJob:
name: str
meta_line: str
token: str
class BrotherLabelPrintService:
def __init__(
self,
model: str,
host: str,
port: int,
label_size: str,
) -> None:
self.model = (model or "QL-710W").strip()
self.host = (host or "").strip()
self.port = int(port or 9100)
self.label_size = self._normalize_label_size((label_size or "62").strip())
self.label_spec = self._resolve_label_spec(self.label_size)
self.printable_width = self._resolve_printable_width(self.label_size)
self.printable_height = self._resolve_printable_height(self.label_size)
self.is_die_cut = bool(self.label_spec and getattr(self.label_spec, "form_factor", None) and "DIE_CUT" in str(getattr(self.label_spec, "form_factor", "")))
@property
def printer_identifier(self) -> str:
return f"tcp://{self.host}:{self.port}"
def print_jobs(self, jobs: Iterable[LabelJob]) -> int:
if not self.host:
raise ValueError("Printer host is missing")
if not send or not convert or not BrotherQLRaster:
raise RuntimeError("brother_ql library is not installed in this environment")
send_func = send
convert_func = convert
raster_cls = BrotherQLRaster
rendered_images = [self._build_label_image(job) for job in jobs]
if not rendered_images:
return 0
qlr = raster_cls(self.model)
instructions = convert_func(
qlr=qlr,
images=rendered_images,
label=self.label_size,
rotate='auto' if self.is_die_cut else 0,
cut=True,
dither=False,
compress=False,
red=False,
dpi_600=False,
)
self._send_to_printer(instructions, send_func)
return len(rendered_images)
def _send_to_printer(self, instructions: List[bytes], send_func) -> None:
target = self.printer_identifier
# brother_ql helper changed call signature across versions.
try:
send_func(instructions, target, "network", blocking=True)
return
except TypeError:
pass
try:
send_func(instructions=instructions, printer_identifier=target, backend_identifier="network", blocking=True)
return
except TypeError:
pass
# Final fallback to raw socket stream for network printers.
payload = b"".join(instructions)
with socket.create_connection((self.host, self.port), timeout=10) as conn:
conn.sendall(payload)
def _build_label_image(self, job: LabelJob) -> Image.Image:
width = self.printable_width
height = self.printable_height if self.printable_height > 0 else 220
image = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(image)
font_title = ImageFont.load_default()
font_meta = ImageFont.load_default()
font_token = ImageFont.load_default()
title = (job.name or "Ukendt enhed")[:52]
meta = (job.meta_line or "-")[:88]
token = (job.token or "")[:64]
left = 12
top = 8
right = max(left + 1, width - 12)
# Compact layout for die-cut labels to fit exact printable area.
if self.is_die_cut:
title_y = top
meta_y = title_y + 18
barcode_y = meta_y + 16
token_y = min(height - 14, barcode_y + max(26, int(height * 0.28)) + 4)
bar_height = max(24, min(int(height * 0.28), height - barcode_y - 22))
else:
title_y = 12
meta_y = 34
barcode_y = 64
token_y = min(height - 16, 170)
bar_height = max(48, min(92, height - barcode_y - 26))
draw.text((left, title_y), title, fill="black", font=font_title)
draw.text((left, meta_y), meta, fill="black", font=font_meta)
self._draw_code39(draw, token, x=left, y=barcode_y, max_width=max(60, right - left), bar_height=bar_height)
draw.text((left, token_y), token, fill="black", font=font_token)
return image
def _normalize_label_size(self, label_size: str) -> str:
wanted = str(label_size or "").strip()
if wanted == "29":
# Legacy compatibility: old config often used "29" while hardware stock is 62x29 die-cut.
logger.warning("⚠️ Label size '29' mapped to '62x29' for Brother QL hardware labels")
return "62x29"
return wanted or "62"
@staticmethod
def _resolve_label_spec(label_size: str):
if not ALL_LABELS:
return None
wanted = str(label_size or "").strip()
for lbl in ALL_LABELS:
if getattr(lbl, "identifier", "") == wanted:
return lbl
return None
@staticmethod
def _resolve_printable_width(label_size: str) -> int:
default_width = 696 # 62mm endless printable width
if not ALL_LABELS:
return default_width
try:
wanted = str(label_size or "").strip()
for lbl in ALL_LABELS:
if getattr(lbl, "identifier", "") == wanted:
dots = getattr(lbl, "dots_printable", None)
if isinstance(dots, tuple) and len(dots) > 0 and int(dots[0]) > 0:
return int(dots[0])
except Exception:
return default_width
return default_width
@staticmethod
def _resolve_printable_height(label_size: str) -> int:
if not ALL_LABELS:
return 220
try:
wanted = str(label_size or "").strip()
for lbl in ALL_LABELS:
if getattr(lbl, "identifier", "") == wanted:
dots = getattr(lbl, "dots_printable", None)
if isinstance(dots, tuple) and len(dots) > 1 and int(dots[1]) > 0:
return int(dots[1])
return 220
except Exception:
return 220
return 220
def _draw_code39(
self,
draw: ImageDraw.ImageDraw,
value: str,
x: int,
y: int,
max_width: int,
bar_height: int,
) -> None:
safe = "".join(ch for ch in (value or "").upper() if ch in _CODE39_PATTERNS and ch != "*")
if not safe:
safe = "EMPTY"
seq = f"*{safe}*"
# Prefer physically narrower bars first; scanners struggle when Code39
# modules become too wide on small die-cut labels.
variants = [
(1, 2, 0),
(1, 3, 1),
(2, 5, 1),
]
narrow, wide, gap = variants[0]
for candidate in variants:
c_narrow, c_wide, c_gap = candidate
width = self._code39_width(seq, c_narrow, c_wide, c_gap)
if width <= max_width:
narrow, wide, gap = c_narrow, c_wide, c_gap
break
cursor = x
for ch in seq:
pattern = _CODE39_PATTERNS[ch]
for idx, code in enumerate(pattern):
stroke = wide if code == "w" else narrow
if idx % 2 == 0:
draw.rectangle([cursor, y, cursor + stroke - 1, y + bar_height], fill="black")
cursor += stroke
if idx < len(pattern) - 1:
cursor += gap
cursor += gap
@staticmethod
def _code39_width(sequence: str, narrow: int, wide: int, gap: int) -> int:
total = 0
for ch in sequence:
pattern = _CODE39_PATTERNS[ch]
for idx, code in enumerate(pattern):
total += wide if code == "w" else narrow
if idx < len(pattern) - 1:
total += gap
total += gap
return total

View File

@ -1,20 +1,59 @@
""" """
CVR.dk API service for looking up Danish company information CVR service for looking up Danish company information.
Free public API - no authentication required
Adapted from OmniSync for BMC Hub Primary provider: FirmaAPI (authenticated).
Legacy fallback: cvrapi.dk when no FirmaAPI key is configured.
""" """
import asyncio import asyncio
import aiohttp import aiohttp
import logging import logging
from typing import Optional, Dict from typing import Optional, Dict
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CVRService: class CVRService:
"""Service for CVR.dk API lookups""" """Service for CVR lookups using FirmaAPI (or legacy fallback)."""
BASE_URL = "https://cvrapi.dk/api" LEGACY_BASE_URL = "https://cvrapi.dk/api"
@property
def firmaapi_base_url(self) -> str:
return settings.FIRMAAPI_BASE_URL.rstrip("/")
@property
def firmaapi_timeout(self) -> aiohttp.ClientTimeout:
return aiohttp.ClientTimeout(total=settings.FIRMAAPI_TIMEOUT_SECONDS)
@property
def has_firmaapi_key(self) -> bool:
return bool((settings.FIRMAAPI_API_KEY or "").strip())
def _firmaapi_headers(self) -> Dict[str, str]:
api_key = (settings.FIRMAAPI_API_KEY or "").strip()
return {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
}
@staticmethod
def _normalize_payload(payload: Dict) -> Dict:
return {
"cvr": payload.get("cvr") or payload.get("vat"),
"name": payload.get("name"),
"address": payload.get("address"),
"city": payload.get("city"),
"zipcode": payload.get("zipcode"),
"postal_code": payload.get("zipcode") or payload.get("postal_code"),
"country": payload.get("country") or "DK",
"phone": payload.get("phone"),
"email": payload.get("email"),
"website": payload.get("website"),
"status": payload.get("status"),
"source": "firmaapi" if payload.get("meta", {}).get("source") == "FirmaAPI" else payload.get("source", "firmaapi"),
}
async def lookup_by_name(self, company_name: str) -> Optional[Dict]: async def lookup_by_name(self, company_name: str) -> Optional[Dict]:
""" """
@ -33,41 +72,42 @@ class CVRService:
clean_name = company_name.strip() clean_name = company_name.strip()
try: try:
params = { if self.has_firmaapi_key:
'search': clean_name,
'country': 'dk'
}
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
f"{self.BASE_URL}", f"{self.firmaapi_base_url}/company/search",
params=params, params={"q": clean_name, "limit": 1},
timeout=aiohttp.ClientTimeout(total=10) headers=self._firmaapi_headers(),
timeout=self.firmaapi_timeout,
) as response: ) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
results = data.get("results") or []
if data and 'vat' in data: if results:
logger.info(f"✅ Found CVR {data['vat']} for '{company_name}'") match = results[0]
return { logger.info("✅ Found CVR %s for '%s' via FirmaAPI", match.get("cvr"), company_name)
'cvr': data.get('vat'), return self._normalize_payload(match)
'name': data.get('name'),
'address': data.get('address'),
'city': data.get('city'),
'zipcode': data.get('zipcode'),
'country': data.get('country'),
'phone': data.get('phone'),
'email': data.get('email'),
'vat': data.get('vat'),
'status': data.get('status')
}
elif response.status == 404:
logger.warning(f"⚠️ No CVR found for '{company_name}'")
return None return None
else: if response.status == 404:
logger.error(f"❌ CVR API error {response.status} for '{company_name}'") return None
detail = await response.text()
logger.error("❌ FirmaAPI name lookup error %s for '%s': %s", response.status, company_name, detail[:240])
return None
# Legacy fallback without API key
params = {"search": clean_name, "country": "dk"}
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.LEGACY_BASE_URL}",
params=params,
timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status == 200:
data = await response.json()
if data and "vat" in data:
return self._normalize_payload(data)
return None return None
except asyncio.TimeoutError: except asyncio.TimeoutError:
@ -99,31 +139,37 @@ class CVRService:
return None return None
try: try:
if self.has_firmaapi_key:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
f"{self.BASE_URL}", f"{self.firmaapi_base_url}/company/{cvr_clean}",
params={'vat': cvr_clean, 'country': 'dk'}, headers=self._firmaapi_headers(),
timeout=aiohttp.ClientTimeout(total=10) timeout=self.firmaapi_timeout,
) as response: ) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
logger.info("✅ Validated CVR %s via FirmaAPI", cvr_clean)
return self._normalize_payload(data)
if data and 'vat' in data: if response.status in (400, 404):
logger.info(f"✅ Validated CVR {cvr_clean}") return None
return {
'cvr': data.get('vat'),
'name': data.get('name'),
'address': data.get('address'),
'city': data.get('city'),
'zipcode': data.get('zipcode'),
'postal_code': data.get('zipcode'), # Alias for consistency
'country': data.get('country'),
'phone': data.get('phone'),
'email': data.get('email'),
'vat': data.get('vat'),
'status': data.get('status')
}
detail = await response.text()
logger.error("❌ FirmaAPI CVR lookup error %s for %s: %s", response.status, cvr_clean, detail[:240])
return None
# Legacy fallback without API key
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.LEGACY_BASE_URL}",
params={"vat": cvr_clean, "country": "dk"},
timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status == 200:
data = await response.json()
if data and "vat" in data:
logger.info("✅ Validated CVR %s via legacy CVR API", cvr_clean)
return self._normalize_payload(data)
return None return None
except Exception as e: except Exception as e:

View File

@ -767,11 +767,99 @@ class EmailService:
result = execute_query(query, (message_id,)) result = execute_query(query, (message_id,))
return len(result) > 0 return len(result) > 0
def _adopt_parent_thread_key(self, email_data: Dict, derived_thread_key: Optional[str]) -> Optional[str]:
"""Look up parent emails by References/In-Reply-To and adopt their thread_key
so outgoing+incoming emails share the same canonical group key."""
# Strategy 1: If the email has an explicit provider thread key (e.g. Graph
# conversationId), check if ANY existing email in the DB already uses it as
# its thread_key. ConversationId is the most reliable stable identifier
# across all emails in an Exchange conversation.
explicit_thread_key = self._normalize_message_id_value(email_data.get("thread_key"))
if explicit_thread_key:
try:
rows = execute_query(
"""
SELECT thread_key
FROM email_messages
WHERE deleted_at IS NULL
AND LOWER(REGEXP_REPLACE(COALESCE(thread_key, ''), '[<>\\s]', '', 'g')) = %s
LIMIT 1
""",
(explicit_thread_key,),
)
if rows:
logger.info(
"🧵 Adopted conversationId thread_key '%s' for incoming email (derived was '%s')",
explicit_thread_key,
derived_thread_key,
)
return explicit_thread_key
except Exception as e:
logger.warning("⚠️ Failed conversationId thread_key lookup: %s", e)
# Strategy 2: Look up parent emails by message_id matching our
# References/In-Reply-To headers.
parent_ids: List[str] = []
ref_ids = self._extract_reference_ids(email_data.get("email_references"))
parent_ids.extend(ref_ids)
in_reply = self._normalize_message_id_value(email_data.get("in_reply_to"))
if in_reply and in_reply not in parent_ids:
parent_ids.append(in_reply)
if not parent_ids:
# Strategy 3: No thread headers at all — try conversationId as thread_key
# even if no existing email has it yet (new conversation from Graph).
if explicit_thread_key:
return explicit_thread_key
return derived_thread_key
# Query parent emails that already have a thread_key stored
placeholders = ",".join(["%s"] * len(parent_ids))
try:
rows = execute_query(
f"""
SELECT thread_key
FROM email_messages
WHERE deleted_at IS NULL
AND thread_key IS NOT NULL
AND TRIM(thread_key) != ''
AND LOWER(REGEXP_REPLACE(COALESCE(message_id, ''), '[<>\\s]', '', 'g')) IN ({placeholders})
ORDER BY received_date ASC
LIMIT 1
""",
tuple(parent_ids),
)
if rows and rows[0].get("thread_key"):
adopted = self._normalize_message_id_value(rows[0]["thread_key"])
if adopted:
logger.info(
"🧵 Adopted parent thread_key '%s' for incoming email (derived was '%s')",
adopted,
derived_thread_key,
)
return adopted
except Exception as e:
logger.warning("⚠️ Failed to adopt parent thread_key: %s", e)
# Fallback: prefer the explicit conversationId over derived References[0]
# since the References message-id often doesn't match any stored message_id
if explicit_thread_key:
return explicit_thread_key
return derived_thread_key
async def save_email(self, email_data: Dict) -> Optional[int]: async def save_email(self, email_data: Dict) -> Optional[int]:
"""Save email to database""" """Save email to database"""
try: try:
thread_key = self._derive_thread_key(email_data) thread_key = self._derive_thread_key(email_data)
# When this email is a reply, look up the parent email(s) by
# message_id matching our References/In-Reply-To. If the parent
# already has a thread_key stored, adopt it so both emails share the
# same canonical key and are grouped in the same visual thread.
thread_key = self._adopt_parent_thread_key(email_data, thread_key)
try: try:
query = """ query = """
INSERT INTO email_messages INSERT INTO email_messages

View File

@ -11,10 +11,12 @@ import re
import json import json
import hashlib import hashlib
import shutil import shutil
import io
from pathlib import Path from pathlib import Path
from decimal import Decimal from decimal import Decimal
from uuid import uuid4
from app.core.database import execute_query, execute_insert, execute_update from app.core.database import execute_query, execute_insert, execute_update, table_has_column
from app.core.config import settings from app.core.config import settings
from app.services.email_activity_logger import email_activity_logger from app.services.email_activity_logger import email_activity_logger
@ -38,6 +40,8 @@ class EmailWorkflowService:
'recording' 'recording'
} }
_SCAN_TOKEN_PATTERN = re.compile(r'\bBMCSCAN-[A-Z0-9-]{10,100}\b', re.IGNORECASE)
async def execute_workflows(self, email_data: Dict) -> Dict: async def execute_workflows(self, email_data: Dict) -> Dict:
""" """
Execute all matching workflows for an email Execute all matching workflows for an email
@ -91,12 +95,17 @@ class EmailWorkflowService:
logger.info("✅ Bankruptcy system workflow executed successfully") logger.info("✅ Bankruptcy system workflow executed successfully")
# Special System Workflow: Helpdesk SAG routing # Special System Workflow: Helpdesk SAG routing
# - If SAG/tråd-hint findes => forsøg altid routing til eksisterende sag # - If SAG/tråd-hint findes => forsøg routing til eksisterende sag
# - Newsletters/spam skip routing ENTIRELY (even with thread hints)
# - Uden hints: brug klassifikationsgating som før # - Uden hints: brug klassifikationsgating som før
HARD_SKIP = {'newsletter', 'spam'}
should_try_helpdesk = ( should_try_helpdesk = (
classification not in HARD_SKIP
and (
classification not in self.HELPDESK_SKIP_CLASSIFICATIONS classification not in self.HELPDESK_SKIP_CLASSIFICATIONS
or has_hint or has_hint
) )
)
if should_try_helpdesk: if should_try_helpdesk:
helpdesk_result = await self._handle_helpdesk_sag_routing(email_data) helpdesk_result = await self._handle_helpdesk_sag_routing(email_data)
@ -223,12 +232,16 @@ class EmailWorkflowService:
return domain or None return domain or None
def has_helpdesk_routing_hint(self, email_data: Dict) -> bool: def has_helpdesk_routing_hint(self, email_data: Dict) -> bool:
"""Return True when email has explicit routing hints (SAG or thread headers/key).""" """Return True when email has explicit routing hints (SAG tag, BMCid, or reply headers).
if self._extract_sag_id(email_data):
NOTE: A bare thread_key (Graph conversationId) is NOT a routing hint
because every Graph email has one, including newsletters and spam.
Only actual reply indicators (In-Reply-To, References), explicit
SAG tags, or BMCid markers count as hints."""
if self._extract_bmc_id(email_data):
return True return True
explicit_thread_key = self._normalize_message_id(email_data.get('thread_key')) if self._extract_sag_id(email_data):
if explicit_thread_key:
return True return True
if self._normalize_message_id(email_data.get('in_reply_to')): if self._normalize_message_id(email_data.get('in_reply_to')):
@ -239,7 +252,33 @@ class EmailWorkflowService:
return False return False
def _extract_bmc_id(self, email_data: Dict) -> Optional[Dict[str, Any]]:
"""Extract structured BMCid from email body/subject.
Returns dict with 'sag_id' (int) and 'thread_suffix' (str, e.g. '472193')
or None if no BMCid is found.
"""
candidates = [
email_data.get('body_html') or '',
email_data.get('body_text') or '',
email_data.get('subject') or '',
]
pattern = r'\bBMCid\s*:\s*s(\d+)t(\d+)\b'
for value in candidates:
match = re.search(pattern, value, re.IGNORECASE)
if match:
return {
'sag_id': int(match.group(1)),
'thread_suffix': match.group(2),
}
return None
def _extract_sag_id(self, email_data: Dict) -> Optional[int]: def _extract_sag_id(self, email_data: Dict) -> Optional[int]:
# First try structured BMCid (most reliable)
bmc_id = self._extract_bmc_id(email_data)
if bmc_id:
return bmc_id['sag_id']
candidates = [ candidates = [
email_data.get('subject') or '', email_data.get('subject') or '',
email_data.get('in_reply_to') or '', email_data.get('in_reply_to') or '',
@ -249,14 +288,15 @@ class EmailWorkflowService:
] ]
# Accept both strict and human variants used in real subjects, e.g.: # Accept both strict and human variants used in real subjects, e.g.:
# - [SAG-53] (hidden/subject prefix)
# - SAG-53 # - SAG-53
# - SAG #53 # - SAG #53
# - Sag 53 # - Sag 53
sag_patterns = [ sag_patterns = [
r'\[SAG-(\d+)\]',
r'\bSAG-(\d+)\b', r'\bSAG-(\d+)\b',
r'\bSAG\s*#\s*(\d+)\b', r'\bSAG\s*#\s*(\d+)\b',
r'\bSAG\s+(\d+)\b', r'\bSAG\s+(\d+)\b',
r'\bBMCid\s*:\s*s(\d+)t\d+\b',
] ]
for value in candidates: for value in candidates:
@ -327,11 +367,14 @@ class EmailWorkflowService:
FROM sag_emails se FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id JOIN email_messages em ON em.id = se.email_id
WHERE em.deleted_at IS NULL WHERE em.deleted_at IS NULL
AND LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s AND (
LOWER(REGEXP_REPLACE(COALESCE(em.thread_key, ''), '[<>\\s]', '', 'g')) = %s
OR LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) = %s
)
ORDER BY se.created_at DESC ORDER BY se.created_at DESC
LIMIT 1 LIMIT 1
""", """,
(thread_key,) (thread_key, thread_key)
) )
return rows[0]['sag_id'] if rows else None return rows[0]['sag_id'] if rows else None
except Exception: except Exception:
@ -357,11 +400,23 @@ class EmailWorkflowService:
) )
return rows[0]['sag_id'] if rows else None return rows[0]['sag_id'] if rows else None
# Sender domains that should never trigger customer-domain SAG creation.
# Includes own sending domain and common automated senders.
_IGNORED_SENDER_DOMAINS = {
'bmcnetworks.dk',
'bmchub.local',
}
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]: def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
if not domain: if not domain:
return None return None
domain = domain.lower().strip() domain = domain.lower().strip()
# Never match the system's own sending domain as a customer
if domain in self._IGNORED_SENDER_DOMAINS:
return None
domain_alt = domain[4:] if domain.startswith('www.') else f"www.{domain}" domain_alt = domain[4:] if domain.startswith('www.') else f"www.{domain}"
query = """ query = """
@ -378,6 +433,114 @@ class EmailWorkflowService:
rows = execute_query(query, (domain, domain_alt)) rows = execute_query(query, (domain, domain_alt))
return rows[0] if rows else None return rows[0] if rows else None
def _find_thread_key_by_bmc_suffix(self, sag_id: int, thread_suffix: str) -> Optional[str]:
"""Find the thread_key of an outgoing email whose BMCid matches s{sag_id}t{thread_suffix}."""
try:
# Legacy compatibility: older outbound emails used t001 when the
# provisional thread key was unknown. In that case, pick the most
# recent outbound thread key in the same case as best effort.
if str(thread_suffix) == '001':
fallback = execute_query(
"""
SELECT em.thread_key
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE se.sag_id = %s
AND em.deleted_at IS NULL
AND em.thread_key IS NOT NULL
AND TRIM(em.thread_key) != ''
AND LOWER(COALESCE(em.sender_email, '')) = %s
ORDER BY em.received_date DESC
LIMIT 1
""",
(sag_id, 'noreply@bmcnetworks.dk'),
)
if fallback and fallback[0].get('thread_key'):
return fallback[0]['thread_key']
rows = execute_query(
"""
SELECT em.thread_key
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE se.sag_id = %s
AND em.deleted_at IS NULL
AND em.thread_key IS NOT NULL
AND TRIM(em.thread_key) != ''
ORDER BY em.received_date DESC
""",
(sag_id,),
)
if not rows:
return None
# Rebuild the BMCid suffix for each candidate thread_key
# and return the one that matches our target suffix.
for row in rows:
tk = row['thread_key']
normalized = re.sub(r"[^a-z0-9]+", "", str(tk).lower())
if not normalized:
continue
digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()
candidate_suffix = str((int(digest[:8], 16) % 900000) + 100000)
if candidate_suffix == thread_suffix:
return tk
return None
except Exception as e:
logger.warning("⚠️ Failed BMCid thread_key lookup: %s", e)
return None
def _update_email_thread_key(self, email_id: int, thread_key: str) -> None:
"""Set the thread_key on an email so it groups correctly."""
execute_update(
"UPDATE email_messages SET thread_key = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(thread_key, email_id),
)
async def _finalize_sag_routing(
self, email_id: int, email_data: Dict, sag_id: int, routing_source: str
) -> Dict[str, Any]:
"""Link an email to an existing SAG and mark as processed."""
case_rows = execute_query(
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not case_rows:
logger.warning("⚠️ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
case = case_rows[0]
self._add_helpdesk_comment(sag_id, email_data)
self._link_email_to_sag(sag_id, email_id)
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
customer_id = COALESCE(customer_id, %s),
status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
auto_processed = true
WHERE id = %s
""",
(sag_id, case.get('customer_id'), email_id),
)
token_for_attach = None
token_route = self._resolve_scan_token_route(email_id, email_data)
if token_route:
token_for_attach = token_route.get('token')
self._auto_attach_scanner_email(email_id, sag_id, token_for_attach)
return {
'status': 'completed',
'action': 'updated_existing_sag',
'sag_id': sag_id,
'customer_id': case.get('customer_id'),
'routing_source': routing_source,
}
def _link_email_to_sag(self, sag_id: int, email_id: int) -> None: def _link_email_to_sag(self, sag_id: int, email_id: int) -> None:
execute_update( execute_update(
""" """
@ -390,6 +553,379 @@ class EmailWorkflowService:
(sag_id, email_id, sag_id, email_id) (sag_id, email_id, sag_id, email_id)
) )
def _extract_scan_tokens(self, *values: Optional[str]) -> List[str]:
tokens: List[str] = []
for value in values:
if not value:
continue
found = self._SCAN_TOKEN_PATTERN.findall(str(value))
if found:
tokens.extend(token.upper() for token in found)
return list(dict.fromkeys(tokens))
def _resolve_scan_token_route(self, email_id: int, email_data: Dict) -> Optional[Dict[str, Any]]:
text_tokens = self._extract_scan_tokens(
email_data.get('subject'),
email_data.get('body_text'),
email_data.get('body_html'),
email_data.get('in_reply_to'),
email_data.get('email_references'),
)
filename_tokens: List[str] = []
attachment_content_tokens: List[str] = []
try:
attachment_rows = execute_query(
"""
SELECT filename, content_type, content_data, file_path
FROM email_attachments
WHERE email_id = %s
ORDER BY id ASC
""",
(email_id,),
) or []
for row in attachment_rows:
filename_tokens.extend(self._extract_scan_tokens(row.get('filename')))
attachment_content_tokens.extend(
self._extract_scan_tokens_from_attachment(
filename=row.get('filename'),
content_type=row.get('content_type'),
content_data=row.get('content_data'),
file_path=row.get('file_path'),
)
)
except Exception as exc:
logger.warning("⚠️ Failed to inspect attachment filenames for scan token: %s", exc)
all_tokens = list(dict.fromkeys(text_tokens + filename_tokens + attachment_content_tokens))
if not all_tokens:
return self._resolve_scan_route_from_scanner_headers(email_data)
placeholders = ','.join(['%s'] * len(all_tokens))
try:
rows = execute_query(
f"""
SELECT token, sag_id, token_type
FROM sag_document_tokens
WHERE token IN ({placeholders})
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
ORDER BY consumed_at IS NULL DESC, created_at DESC
LIMIT 1
""",
tuple(all_tokens),
)
if rows:
return rows[0]
# Fallback for scanner workflows where token only exists in barcode image
# and therefore not in plain text metadata.
return self._resolve_scan_route_from_scanner_headers(email_data)
except Exception as exc:
logger.warning("⚠️ Scan token lookup failed: %s", exc)
return self._resolve_scan_route_from_scanner_headers(email_data)
def _extract_scan_tokens_from_attachment(
self,
filename: Optional[str],
content_type: Optional[str],
content_data: Optional[Any],
file_path: Optional[str],
) -> List[str]:
tokens: List[str] = []
payload: Optional[bytes] = None
if content_data is not None:
try:
payload = bytes(content_data)
except Exception:
payload = None
if payload is None and file_path:
try:
payload = Path(file_path).read_bytes()
except Exception:
payload = None
if not payload:
return tokens
# 1) Cheap text extraction directly from bytes catches tokens in OCR-layer PDFs,
# plain text files, or metadata-rich attachments.
try:
sample = payload[:1_500_000]
tokens.extend(self._extract_scan_tokens(sample.decode('utf-8', errors='ignore')))
tokens.extend(self._extract_scan_tokens(sample.decode('latin-1', errors='ignore')))
except Exception:
pass
ext = (Path(str(filename or '')).suffix or '').lower().strip('.')
ctype = (content_type or '').lower()
# 2) PDF text-layer extraction (when available) for scanned documents with OCR.
if ext == 'pdf' or 'pdf' in ctype:
try:
from pypdf import PdfReader # type: ignore
reader = PdfReader(io.BytesIO(payload))
text_chunks: List[str] = []
for page in reader.pages[:5]:
extracted = page.extract_text() or ''
if extracted:
text_chunks.append(extracted)
if text_chunks:
tokens.extend(self._extract_scan_tokens("\n".join(text_chunks)))
except Exception:
pass
# 3) Decode barcode directly from scanned attachments.
# This catches cases where BMCSCAN exists only as a barcode image.
try:
if ext == 'pdf' or 'pdf' in ctype:
tokens.extend(self._extract_scan_tokens_from_pdf_barcode(payload))
else:
tokens.extend(self._extract_scan_tokens_from_image_barcode(payload))
except Exception:
pass
return list(dict.fromkeys(token.upper() for token in tokens if token))
def _extract_scan_tokens_from_image_barcode(self, payload: bytes) -> List[str]:
try:
from PIL import Image # type: ignore
from pyzbar.pyzbar import decode as zbar_decode # type: ignore
except Exception:
return []
try:
image = Image.open(io.BytesIO(payload))
except Exception:
return []
decoded_tokens: List[str] = []
variants = [image]
try:
variants.append(image.convert('L'))
variants.append(image.convert('L').point(lambda p: 255 if p > 140 else 0))
except Exception:
pass
for variant in variants:
try:
for item in zbar_decode(variant):
raw = item.data.decode('utf-8', errors='ignore')
decoded_tokens.extend(self._extract_scan_tokens(raw))
except Exception:
continue
return list(dict.fromkeys(decoded_tokens))
def _extract_scan_tokens_from_pdf_barcode(self, payload: bytes) -> List[str]:
try:
import pypdfium2 as pdfium # type: ignore
from pyzbar.pyzbar import decode as zbar_decode # type: ignore
except Exception:
return []
decoded_tokens: List[str] = []
try:
doc = pdfium.PdfDocument(io.BytesIO(payload))
except Exception:
return []
page_count = min(len(doc), 3)
for page_index in range(page_count):
page = None
try:
page = doc.get_page(page_index)
bitmap = page.render(scale=2.2)
pil_image = bitmap.to_pil()
for variant in (pil_image, pil_image.convert('L')):
for item in zbar_decode(variant):
raw = item.data.decode('utf-8', errors='ignore')
decoded_tokens.extend(self._extract_scan_tokens(raw))
except Exception:
continue
finally:
try:
if page is not None:
page.close()
except Exception:
pass
return list(dict.fromkeys(decoded_tokens))
def _resolve_scan_route_from_scanner_headers(self, email_data: Dict) -> Optional[Dict[str, Any]]:
"""Infer case route from scanner-generated message-id timestamps.
Some scanner/MFP flows only include the barcode token inside the attached image/PDF,
while headers contain a timestamped local message-id such as
`<1.20260401075731@172.16.31.35>`. We map that timestamp to the nearest recent,
unconsumed document token.
"""
header_values = [
email_data.get('in_reply_to'),
email_data.get('email_references'),
email_data.get('message_id'),
email_data.get('thread_key'),
]
candidates: List[datetime] = []
ts_pattern = re.compile(r'(20\d{12})')
for raw in header_values:
if not raw:
continue
for match in ts_pattern.findall(str(raw)):
try:
candidates.append(datetime.strptime(match, "%Y%m%d%H%M%S"))
except ValueError:
continue
if not candidates:
return None
for ts in candidates:
try:
rows = execute_query(
"""
SELECT token, sag_id, token_type, created_at
FROM sag_document_tokens
WHERE consumed_at IS NULL
AND created_at BETWEEN %s::timestamp - INTERVAL '90 minutes'
AND %s::timestamp + INTERVAL '20 minutes'
ORDER BY ABS(EXTRACT(EPOCH FROM (created_at - %s::timestamp))) ASC,
CASE WHEN token_type = 'work_order' THEN 0 ELSE 1 END,
id DESC
LIMIT 1
""",
(ts, ts, ts),
) or []
if rows:
row = rows[0]
logger.info(
"🔎 Inferred scanner route via header timestamp %s -> SAG-%s (%s)",
ts.isoformat(),
row.get('sag_id'),
row.get('token'),
)
return {
'token': row.get('token'),
'sag_id': row.get('sag_id'),
'token_type': row.get('token_type'),
}
except Exception as exc:
logger.warning("⚠️ Scanner header timestamp route lookup failed: %s", exc)
return None
def _copy_email_attachments_to_case(self, email_id: int, sag_id: int, source_token: Optional[str]) -> int:
attachments = execute_query(
"""
SELECT filename, content_type, size_bytes, file_path, content_data
FROM email_attachments
WHERE email_id = %s
ORDER BY id ASC
""",
(email_id,),
) or []
if not attachments:
return 0
upload_base = Path(settings.UPLOAD_DIR).resolve()
(upload_base / "sag_files").mkdir(parents=True, exist_ok=True)
has_source_email = table_has_column("sag_files", "source_email_id")
has_source_type = table_has_column("sag_files", "source_type")
has_source_token = table_has_column("sag_files", "source_token")
copied = 0
for attachment in attachments:
filename = Path(attachment.get('filename') or 'scanned-document.bin').name
if has_source_email:
existing = execute_query(
"""
SELECT 1
FROM sag_files
WHERE sag_id = %s
AND source_email_id = %s
AND filename = %s
LIMIT 1
""",
(sag_id, email_id, filename),
) or []
if existing:
continue
payload = attachment.get('content_data')
if payload is None and attachment.get('file_path'):
try:
payload = Path(attachment['file_path']).read_bytes()
except Exception as exc:
logger.warning("⚠️ Could not read attachment file (%s): %s", filename, exc)
continue
if payload is None:
continue
raw_payload = bytes(payload)
stored_name = f"sag_files/{uuid4().hex}_{filename}"
target_path = upload_base / stored_name
try:
target_path.write_bytes(raw_payload)
except Exception as exc:
logger.warning("⚠️ Could not write case file from attachment (%s): %s", filename, exc)
continue
columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"]
values: List[Any] = [
sag_id,
filename,
attachment.get('content_type') or 'application/octet-stream',
attachment.get('size_bytes') or len(raw_payload),
stored_name,
]
if has_source_email:
columns.append("source_email_id")
values.append(email_id)
if has_source_type:
columns.append("source_type")
values.append("scanner_email")
if has_source_token:
columns.append("source_token")
values.append(source_token)
execute_query(
f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})",
tuple(values),
)
copied += 1
return copied
def _auto_attach_scanner_email(self, email_id: int, sag_id: int, token: Optional[str]) -> None:
try:
copied = self._copy_email_attachments_to_case(email_id, sag_id, token)
if copied > 0:
logger.info("📎 Auto-attached %s attachment(s) from email %s to SAG-%s", copied, email_id, sag_id)
if token:
execute_update(
"""
UPDATE sag_document_tokens
SET consumed_at = COALESCE(consumed_at, CURRENT_TIMESTAMP),
consumed_email_id = COALESCE(consumed_email_id, %s)
WHERE token = %s
""",
(email_id, token),
)
except Exception as exc:
logger.warning("⚠️ Scanner auto-attach failed for email %s: %s", email_id, exc)
def _strip_quoted_email_text(self, body_text: str) -> str: def _strip_quoted_email_text(self, body_text: str) -> str:
"""Return only the newest reply content (remove quoted history/signatures).""" """Return only the newest reply content (remove quoted history/signatures)."""
if not body_text: if not body_text:
@ -491,6 +1027,41 @@ class EmailWorkflowService:
sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key) sag_id_from_thread_key = self._find_sag_id_from_thread_key(derived_thread_key)
sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data) sag_id_from_thread = self._find_sag_id_from_thread_headers(email_data)
sag_id_from_tag = self._extract_sag_id(email_data) sag_id_from_tag = self._extract_sag_id(email_data)
scan_token_route = self._resolve_scan_token_route(email_id, email_data)
if scan_token_route and scan_token_route.get('sag_id'):
matched_sag_id = int(scan_token_route['sag_id'])
logger.info("🔎 Scan token matched email %s to SAG-%s", email_id, matched_sag_id)
return await self._finalize_sag_routing(email_id, email_data, matched_sag_id, 'scan_token')
# Priority 0: BMCid is the most reliable signal — it's our own hidden
# marker embedded in every outgoing case email. When present, it
# provides the sag_id directly and the thread_suffix lets us adopt
# the correct thread_key for multi-thread SAGs.
bmc_id = self._extract_bmc_id(email_data)
if bmc_id:
bmc_sag_id = bmc_id['sag_id']
bmc_thread_suffix = bmc_id['thread_suffix']
# Look up the thread_key of the outgoing email whose BMCid matches
bmc_thread_key = self._find_thread_key_by_bmc_suffix(bmc_sag_id, bmc_thread_suffix)
if bmc_thread_key:
# Adopt the outgoing email's thread_key so reply groups correctly
self._update_email_thread_key(email_id, bmc_thread_key)
logger.info(
"🔖 BMCid s%st%s matched → SAG-%s (thread_key=%s)",
bmc_sag_id, bmc_thread_suffix, bmc_sag_id, bmc_thread_key,
)
sag_id = bmc_sag_id
routing_source = 'bmc_id'
# Skip the remaining priority chain — BMCid is authoritative
return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
# Fallback: try the explicit provider thread key (e.g. Graph conversationId)
# separately when the derived key (References[0]) differs from it.
provider_thread_key = self._normalize_message_id(email_data.get('thread_key'))
sag_id_from_provider = None
if provider_thread_key and provider_thread_key != derived_thread_key:
sag_id_from_provider = self._find_sag_id_from_thread_key(provider_thread_key)
routing_source = None routing_source = None
sag_id = None sag_id = None
@ -513,6 +1084,11 @@ class EmailWorkflowService:
routing_source = 'thread_headers' routing_source = 'thread_headers'
logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id) logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id)
if sag_id_from_provider and not sag_id:
sag_id = sag_id_from_provider
routing_source = 'provider_thread_key'
logger.info("🧵 Matched email %s to SAG-%s via provider thread key (conversationId)", email_id, sag_id)
if sag_id_from_tag: if sag_id_from_tag:
if sag_id and sag_id != sag_id_from_tag: if sag_id and sag_id != sag_id_from_tag:
logger.warning( logger.warning(
@ -528,40 +1104,7 @@ class EmailWorkflowService:
# 1) Existing SAG via subject/headers # 1) Existing SAG via subject/headers
if sag_id: if sag_id:
case_rows = execute_query( return await self._finalize_sag_routing(email_id, email_data, sag_id, routing_source)
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
)
if not case_rows:
logger.warning("⚠️ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
case = case_rows[0]
self._add_helpdesk_comment(sag_id, email_data)
self._link_email_to_sag(sag_id, email_id)
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
customer_id = COALESCE(customer_id, %s),
status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
auto_processed = true
WHERE id = %s
""",
(sag_id, case.get('customer_id'), email_id)
)
return {
'status': 'completed',
'action': 'updated_existing_sag',
'sag_id': sag_id,
'customer_id': case.get('customer_id'),
'routing_source': routing_source
}
# 2) No SAG id -> create only if sender domain belongs to known customer # 2) No SAG id -> create only if sender domain belongs to known customer
sender_domain = self._extract_sender_domain(email_data) sender_domain = self._extract_sender_domain(email_data)
@ -589,6 +1132,7 @@ class EmailWorkflowService:
(case['id'], customer['id'], email_id) (case['id'], customer['id'], email_id)
) )
self._auto_attach_scanner_email(email_id, case['id'], None)
logger.info("✅ Created SAG-%s from email %s for customer %s", case['id'], email_id, customer['id']) logger.info("✅ Created SAG-%s from email %s for customer %s", case['id'], email_id, customer['id'])
return { return {
'status': 'completed', 'status': 'completed',

View File

@ -102,7 +102,7 @@ class ReminderNotificationService:
) )
# Get user email # Get user email
user_query = "SELECT email FROM users WHERE id = %s" user_query = "SELECT email FROM users WHERE user_id = %s"
user = execute_query(user_query, (user_id,)) user = execute_query(user_query, (user_id,))
user_email = user[0]['email'] if user else None user_email = user[0]['email'] if user else None

View File

@ -0,0 +1,185 @@
import logging
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
class VaultwardenServiceError(Exception):
pass
def _is_configured() -> bool:
return bool((settings.VAULTWARDEN_BASE_URL or "").strip()) and bool((settings.VAULTWARDEN_API_TOKEN or "").strip())
def _base_url() -> str:
return (settings.VAULTWARDEN_BASE_URL or "").strip().rstrip("/")
def _headers() -> Dict[str, str]:
token = (settings.VAULTWARDEN_API_TOKEN or "").strip()
return {
"Authorization": f"Bearer {token}",
"X-API-Token": token,
"Accept": "application/json",
}
def _extract_from_cipher(payload: dict) -> Optional[dict]:
if not isinstance(payload, dict):
return None
login = payload.get("login") or payload.get("Login") or {}
if not isinstance(login, dict):
login = {}
username = login.get("username") or login.get("Username")
password = login.get("password") or login.get("Password")
totp = login.get("totp") or login.get("Totp")
uris = login.get("uris") or login.get("Uris") or []
url = None
if isinstance(uris, list) and uris:
first = uris[0] or {}
if isinstance(first, dict):
url = first.get("uri") or first.get("Uri")
if not any([username, password, totp, url, payload.get("notes") or payload.get("Notes")]):
return None
return {
"item_id": str(payload.get("id") or payload.get("Id") or "") or None,
"item_name": payload.get("name") or payload.get("Name"),
"username": username,
"password": password,
"totp": totp,
"notes": payload.get("notes") or payload.get("Notes"),
"url": url,
}
def _extract_from_custom_payload(payload: Any) -> Optional[dict]:
if isinstance(payload, dict):
direct = {
"item_id": payload.get("item_id") or payload.get("id"),
"item_name": payload.get("item_name") or payload.get("name"),
"username": payload.get("username"),
"password": payload.get("password"),
"totp": payload.get("totp") or payload.get("otp"),
"notes": payload.get("notes"),
"url": payload.get("url"),
}
if any(direct.values()):
return direct
nested = payload.get("data")
if isinstance(nested, dict):
nested_res = _extract_from_custom_payload(nested)
if nested_res:
return nested_res
cipher_res = _extract_from_cipher(payload)
if cipher_res:
return cipher_res
if isinstance(payload, list):
for item in payload:
extracted = _extract_from_custom_payload(item)
if extracted:
return extracted
return None
async def _get_json(client: httpx.AsyncClient, url: str) -> Any:
response = await client.get(url)
if response.status_code == 404:
return None
response.raise_for_status()
if not response.content:
return None
return response.json()
async def resolve_vault_credentials(
*,
preferred_item_id: Optional[str],
fallback_item_ids: List[str],
search_hint: Optional[str],
) -> dict:
if not _is_configured():
return {
"status": "unavailable",
"configured": False,
"message": "Vaultwarden er ikke konfigureret.",
"checked_item_ids": [],
"credential": None,
}
checked_item_ids: List[str] = []
item_id_candidates = [preferred_item_id] + list(fallback_item_ids)
deduped_candidates: List[str] = []
seen = set()
for item_id in item_id_candidates:
candidate = (item_id or "").strip()
if not candidate or candidate in seen:
continue
seen.add(candidate)
deduped_candidates.append(candidate)
timeout = httpx.Timeout(connect=6.0, read=10.0, write=10.0, pool=6.0)
async with httpx.AsyncClient(timeout=timeout, headers=_headers(), follow_redirects=True) as client:
base = _base_url()
for item_id in deduped_candidates:
checked_item_ids.append(item_id)
try:
payload = await _get_json(client, f"{base}/api/ciphers/{quote(item_id)}")
extracted = _extract_from_custom_payload(payload)
if extracted:
return {
"status": "ok",
"configured": True,
"message": "Vault-opslag gennemfoert.",
"checked_item_ids": checked_item_ids,
"credential": extracted,
}
except httpx.HTTPError as exc:
logger.warning("Vaultwarden item lookup failed for id=%s: %s", item_id, exc)
hint = (search_hint or "").strip()
if hint:
encoded_hint = quote(hint)
search_endpoints = [
f"{base}/api/links/credentials?search={encoded_hint}",
f"{base}/api/ciphers?search={encoded_hint}",
f"{base}/api/ciphers?url={encoded_hint}",
]
for endpoint in search_endpoints:
try:
payload = await _get_json(client, endpoint)
extracted = _extract_from_custom_payload(payload)
if extracted:
return {
"status": "ok",
"configured": True,
"message": "Vault-opslag gennemfoert.",
"checked_item_ids": checked_item_ids,
"credential": extracted,
}
except httpx.HTTPError as exc:
logger.info("Vaultwarden search endpoint failed (%s): %s", endpoint, exc)
return {
"status": "not_found",
"configured": True,
"message": "Ingen vault credentials fundet for linket.",
"checked_item_ids": checked_item_ids,
"credential": None,
}

View File

@ -242,6 +242,26 @@ async def update_setting(key: str, setting: SettingUpdate):
(key, setting.value, category, description, value_type, is_public), (key, setting.value, category, description, value_type, is_public),
) )
_label_printer_keys = {
"label_printer_enabled": ("integrations", "Enable direct label printing", "boolean", True),
"label_printer_model": ("integrations", "Brother printer model for direct labels", "string", True),
"label_printer_host": ("integrations", "Brother printer host/IP", "string", True),
"label_printer_port": ("integrations", "Brother printer TCP port", "integer", True),
"label_printer_label_size": ("integrations", "Brother label size code", "string", True),
}
if not result and key in _label_printer_keys:
category, description, value_type, is_public = _label_printer_keys[key]
result = execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
RETURNING *
""",
(key, setting.value, category, description, value_type, is_public),
)
# Mission camera settings may not exist on older hubs before migration. # Mission camera settings may not exist on older hubs before migration.
if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}: if not result and key in {"mission_camera_enabled", "mission_camera_name", "mission_camera_feed_url", "mission_camera_spotlight_seconds", "mission_access_pin"}:
defaults = { defaults = {

View File

@ -259,6 +259,48 @@
<span id="anydeskSaveStatus" class="small text-muted"></span> <span id="anydeskSaveStatus" class="small text-muted"></span>
</div> </div>
</div> </div>
<div class="card p-4 mt-4">
<div class="d-flex align-items-center justify-content-between gap-2 mb-4">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-printer" style="font-size:1.4rem;color:#0f4c75"></i>
<h5 class="mb-0 fw-bold">Brother Label Printer (Direkte print)</h5>
</div>
</div>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label fw-semibold">Aktiver</label>
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" id="labelPrinterEnabled" role="switch">
</div>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Model</label>
<input type="text" class="form-control" id="labelPrinterModel" placeholder="QL-710W" autocomplete="off">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Printer IP / Host</label>
<input type="text" class="form-control" id="labelPrinterHost" placeholder="172.16.31.32" autocomplete="off">
</div>
<div class="col-md-2">
<label class="form-label fw-semibold">Port</label>
<input type="number" class="form-control" id="labelPrinterPort" min="1" max="65535" value="9100">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Label størrelse</label>
<input type="text" class="form-control" id="labelPrinterSize" placeholder="62" autocomplete="off">
</div>
<div class="col-12">
<small class="text-muted">Tip: QL-710W bruger typisk port 9100. Label-størrelse kan fx være <strong>62</strong>.</small>
</div>
</div>
<div class="d-flex align-items-center gap-3 mt-4">
<button class="btn btn-primary" onclick="saveLabelPrinterSettings()">
<i class="bi bi-save me-2"></i>Gem label printer
</button>
<span id="labelPrinterSaveStatus" class="small text-muted"></span>
</div>
</div>
</div> </div>
<!-- Telefoni --> <!-- Telefoni -->
@ -2046,6 +2088,7 @@ async function loadSettings() {
await loadTagsManagement(); await loadTagsManagement();
await loadNextcloudInstances(); await loadNextcloudInstances();
await loadAnydeskSettings(); await loadAnydeskSettings();
await loadLabelPrinterSettings();
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
} }
@ -2162,6 +2205,83 @@ async function saveAnydeskSettings() {
} }
} }
async function loadLabelPrinterSettings() {
const keys = [
'label_printer_enabled',
'label_printer_model',
'label_printer_host',
'label_printer_port',
'label_printer_label_size'
];
try {
const results = await Promise.allSettled(
keys.map(k => fetch(`/api/v1/settings/${k}`, { credentials: 'include' }).then(r => r.ok ? r.json() : null))
);
const vals = {};
results.forEach((r, i) => { if (r.status === 'fulfilled' && r.value) vals[keys[i]] = r.value.value; });
document.getElementById('labelPrinterEnabled').checked = vals.label_printer_enabled === 'true';
document.getElementById('labelPrinterModel').value = vals.label_printer_model || 'QL-710W';
document.getElementById('labelPrinterHost').value = vals.label_printer_host || '172.16.31.32';
document.getElementById('labelPrinterPort').value = vals.label_printer_port || '9100';
document.getElementById('labelPrinterSize').value = vals.label_printer_label_size || '62';
} catch (e) {
console.warn('Label printer settings load failed:', e);
}
}
async function saveLabelPrinterSettings() {
const enabled = document.getElementById('labelPrinterEnabled').checked;
const model = (document.getElementById('labelPrinterModel').value || '').trim() || 'QL-710W';
const host = (document.getElementById('labelPrinterHost').value || '').trim();
const port = (document.getElementById('labelPrinterPort').value || '').trim() || '9100';
const size = (document.getElementById('labelPrinterSize').value || '').trim() || '62';
const statusEl = document.getElementById('labelPrinterSaveStatus');
if (enabled && !host) {
showNotification('Angiv printer IP/host', 'error');
return;
}
if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
showNotification('Ugyldig port', 'error');
return;
}
statusEl.textContent = 'Gemmer...';
statusEl.className = 'small text-muted';
const putSettingStrict = async (key, value) => {
const response = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(value) })
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
}
};
try {
await Promise.all([
putSettingStrict('label_printer_enabled', enabled ? 'true' : 'false'),
putSettingStrict('label_printer_model', model),
putSettingStrict('label_printer_host', host),
putSettingStrict('label_printer_port', String(port)),
putSettingStrict('label_printer_label_size', size),
]);
statusEl.textContent = '✅ Gemt';
statusEl.className = 'small text-success';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
showNotification('Label printer indstillinger gemt', 'success');
} catch (error) {
statusEl.textContent = '❌ Kunne ikke gemme';
statusEl.className = 'small text-danger';
showNotification('Kunne ikke gemme label printer indstillinger', 'error');
}
}
async function loadNextcloudInstances() { async function loadNextcloudInstances() {
try { try {
const response = await fetch('/api/v1/nextcloud/instances'); const response = await fetch('/api/v1/nextcloud/instances');

View File

@ -220,6 +220,7 @@
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li> <li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li> <li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
<li><a class="dropdown-item py-2" href="/links">Links</a></li>
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li> <li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
<li><a class="dropdown-item py-2" href="#">Leads</a></li> <li><a class="dropdown-item py-2" href="#">Leads</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@ -306,13 +307,18 @@
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li> <li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul> </ul>
</div> </div>
<button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)"> <button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i> <i class="bi bi-plus-circle-fill fs-5"></i>
</button> </button>
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);"> <button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
<i class="bi bi-moon-fill"></i> <i class="bi bi-moon-fill"></i>
</button> </button>
<button class="btn btn-light rounded-circle border-0" style="background: var(--accent-light); color: var(--accent);"><i class="bi bi-bell"></i></button> <button class="btn btn-light rounded-circle border-0" id="globalRemindersBtn" style="background: var(--accent-light); color: var(--accent);" title="Åbn reminders">
<i class="bi bi-bell"></i>
</button>
<div class="dropdown"> <div class="dropdown">
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown"> <a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32"> <img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
@ -407,6 +413,21 @@
</div> </div>
</div> </div>
<!-- Email Results -->
<div id="emailResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-envelope me-2"></i>Email
</h6>
<a href="/emails" class="btn btn-sm btn-outline-primary">
<i class="bi bi-envelope-open me-1"></i>Åbn Email
</a>
</div>
<div class="result-items">
<!-- Dynamic results will be inserted here -->
</div>
</div>
<!-- Sales Results --> <!-- Sales Results -->
<div id="salesResults" class="result-section mb-4" style="display: none;"> <div id="salesResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@ -560,8 +581,52 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal')); const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn');
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
const profileModalEl = document.getElementById('profileModal');
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
const globalSearchInput = document.getElementById('globalSearchInput'); const globalSearchInput = document.getElementById('globalSearchInput');
function openGlobalSearchModal() {
searchModal.show();
setTimeout(() => {
if (globalSearchInput) {
globalSearchInput.focus();
}
loadLiveStats();
loadRecentActivity();
}, 300);
}
function openRemindersModalTab() {
if (!profileModalInstance || !profileModalEl) {
return;
}
profileModalInstance.show();
setTimeout(() => {
const remindersTabBtn = document.getElementById('profile-reminders-tab');
if (remindersTabBtn) {
bootstrap.Tab.getOrCreateInstance(remindersTabBtn).show();
}
loadReminderPreferences();
loadProfileReminders();
}, 220);
}
if (searchBubbleBtn) {
searchBubbleBtn.addEventListener('click', (e) => {
e.preventDefault();
openGlobalSearchModal();
});
}
if (remindersBubbleBtn) {
remindersBubbleBtn.addEventListener('click', (e) => {
e.preventDefault();
openRemindersModalTab();
});
}
// Search input listener with debounce // Search input listener with debounce
let searchTimeout; let searchTimeout;
if (globalSearchInput) { if (globalSearchInput) {
@ -583,6 +648,9 @@
navigateResults(-1); navigateResults(-1);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (navigateToSagFromScan(e.target.value)) {
return;
}
selectCurrentResult(); selectCurrentResult();
} }
}); });
@ -593,15 +661,7 @@
// Cmd+K / Ctrl+K for global search // Cmd+K / Ctrl+K for global search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault(); e.preventDefault();
console.log('Cmd+K pressed - opening search modal'); // Debug openGlobalSearchModal();
searchModal.show();
setTimeout(() => {
if (globalSearchInput) {
globalSearchInput.focus();
}
loadLiveStats();
loadRecentActivity();
}, 300);
} }
// '+' key for QuickCreate (not in input fields) // '+' key for QuickCreate (not in input fields)
@ -651,6 +711,7 @@
document.getElementById('workflowActions').style.display = 'none'; document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none'; document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none'; document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none'; if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none'; if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
}); });
@ -811,12 +872,41 @@
} }
} }
function extractSagIdFromScanToken(value) {
const cleaned = String(value || '').toUpperCase().replace(/\s+/g, ' ').trim();
if (!cleaned) return null;
// Scanner tokens from work order and hardware labels
const workOrderMatch = cleaned.match(/\bBMCSCAN-WO-S(\d+)\b/);
if (workOrderMatch) return parseInt(workOrderMatch[1], 10);
const hardwareMatch = cleaned.match(/\bBMCSCAN-HW-(\d+)\b/);
if (hardwareMatch) return parseInt(hardwareMatch[1], 10);
return null;
}
function navigateToSagFromScan(value) {
const sagId = extractSagIdFromScanToken(value);
if (!sagId || Number.isNaN(sagId)) {
return false;
}
window.location.href = `/sag/${sagId}`;
return true;
}
// Global search function // Global search function
async function performGlobalSearch(query) { async function performGlobalSearch(query) {
if (navigateToSagFromScan(query)) {
return;
}
if (!query || query.trim().length < 2) { if (!query || query.trim().length < 2) {
document.getElementById('emptyState').style.display = 'block'; document.getElementById('emptyState').style.display = 'block';
document.getElementById('crmResults').style.display = 'none'; document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none'; document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none'; if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none'; if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
return; return;
@ -888,6 +978,51 @@
console.log('Contacts search not available'); console.log('Contacts search not available');
} }
// Search emails
try {
const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
const emailsData = await emailsResponse.json();
if (Array.isArray(emailsData) && emailsData.length > 0) {
hasResults = true;
const emailResults = document.getElementById('emailResults');
if (emailResults) {
emailResults.style.display = 'block';
const emailList = emailResults.querySelector('.result-items');
if (emailList) {
emailList.innerHTML = emailsData.map(mail => {
const received = mail.received_date
? new Date(mail.received_date).toLocaleString('da-DK')
: '-';
const sender = mail.sender_name || mail.sender_email || '-';
const isUnread = !Boolean(mail.is_read);
return `
<div class="result-item" onclick="window.location.href='/emails?open=${mail.id}'" style="cursor: pointer;">
<div>
<div class="fw-bold">${escapeHtml(mail.subject || '(Ingen emne)')}</div>
<div class="small text-muted">
<i class="bi bi-envelope me-1"></i>${escapeHtml(sender)}
${mail.linked_case_id ? ` • Sag #${mail.linked_case_id}` : ''}
${isUnread ? ' • <span class="text-warning">Ulæst</span>' : ''}
• ${escapeHtml(received)}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`;
}).join('');
}
}
} else {
const emailResults = document.getElementById('emailResults');
if (emailResults) emailResults.style.display = 'none';
}
} catch (e) {
console.log('Email search not available');
const emailResults = document.getElementById('emailResults');
if (emailResults) emailResults.style.display = 'none';
}
// Search hardware // Search hardware
try { try {
const hardwareResponse = await fetch(`/api/v1/hardware?search=${encodeURIComponent(query)}&limit=5`); const hardwareResponse = await fetch(`/api/v1/hardware?search=${encodeURIComponent(query)}&limit=5`);

20
check_threads.sql Normal file
View File

@ -0,0 +1,20 @@
-- Check thread fragmentation per SAG
WITH resolved AS (
SELECT
se.sag_id,
em.id,
em.thread_key,
em.folder,
COALESCE(
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(em.thread_key, '')), '[<>\s]', '', 'g'), ''),
CONCAT('email-', em.id::text)
) AS resolved_key
FROM sag_emails se
JOIN email_messages em ON em.id = se.email_id
WHERE em.deleted_at IS NULL
)
SELECT sag_id, COUNT(DISTINCT resolved_key) as thread_count, COUNT(*) as email_count
FROM resolved
GROUP BY sag_id
HAVING COUNT(DISTINCT resolved_key) > 1
ORDER BY thread_count DESC;

View File

@ -50,7 +50,7 @@ services:
environment: environment:
# Override database URL to point to postgres service # Override database URL to point to postgres service
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub} - DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
- ENABLE_RELOAD=false - ENABLE_RELOAD=${ENABLE_RELOAD:-true}
- APIGW_TOKEN=${APIGW_TOKEN} - APIGW_TOKEN=${APIGW_TOKEN}
- APIGATEWAY_URL=${APIGATEWAY_URL} - APIGATEWAY_URL=${APIGATEWAY_URL}
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS} - APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}

View File

@ -0,0 +1,48 @@
-- Migration 156: Backfill email thread_keys from parent emails
-- Ensures replies inherit the same thread_key as their parent so they group together visually.
-- Step 1: For emails that have in_reply_to or email_references pointing to an existing
-- email with a thread_key, adopt the parent's thread_key.
UPDATE email_messages child
SET thread_key = parent.thread_key,
updated_at = CURRENT_TIMESTAMP
FROM email_messages parent
WHERE child.deleted_at IS NULL
AND parent.deleted_at IS NULL
AND parent.thread_key IS NOT NULL
AND TRIM(parent.thread_key) != ''
AND (
-- Match via in_reply_to -> parent message_id
(
child.in_reply_to IS NOT NULL
AND TRIM(child.in_reply_to) != ''
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
= LOWER(REGEXP_REPLACE(
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.in_reply_to), E'[\\s,]+'))[1],
'[<>\s]', '', 'g'
))
)
OR
-- Match via first reference -> parent message_id
(
child.email_references IS NOT NULL
AND TRIM(child.email_references) != ''
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
= LOWER(REGEXP_REPLACE(
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.email_references), E'[\\s,]+'))[1],
'[<>\s]', '', 'g'
))
)
)
-- Only update if the thread_key would actually change
AND (
child.thread_key IS NULL
OR TRIM(child.thread_key) = ''
OR LOWER(REGEXP_REPLACE(child.thread_key, '[<>\s]', '', 'g'))
!= LOWER(REGEXP_REPLACE(parent.thread_key, '[<>\s]', '', 'g'))
);
-- Step 2: REMOVED - was incorrectly forcing all emails in a SAG to share one thread_key.
-- Each SAG can have multiple independent email threads (different recipients/subjects).
-- Thread grouping is based on actual RFC 5322 threading headers, not SAG membership.
-- See migration 157 for the fix.

View File

@ -0,0 +1,57 @@
-- Migration 157: Fix thread_keys - restore correct per-conversation grouping
-- Migration 156 Step 2 incorrectly forced ALL emails in a SAG to share one thread_key.
-- This migration restores the correct thread_key based on actual email conversation headers.
-- Step 1: Restore thread_key for emails that have a Graph conversationId stored
-- (these were overwritten by the dominant-thread backfill).
-- The conversationId is the most reliable conversation identifier from Exchange/Graph.
-- Step 2: Re-derive thread_keys from actual email headers.
-- Priority: conversationId (if provider) > parent's thread_key > References[0] > In-Reply-To > message_id
-- We re-derive for ALL emails to undo the forced unification.
-- First, recalculate based on actual References/In-Reply-To parent chain.
-- For emails that are replies (have in_reply_to or email_references), adopt the
-- thread_key of the ACTUAL parent email (matched by message_id), not just any email in the SAG.
UPDATE email_messages child
SET thread_key = parent.thread_key,
updated_at = CURRENT_TIMESTAMP
FROM email_messages parent
WHERE child.deleted_at IS NULL
AND parent.deleted_at IS NULL
AND parent.thread_key IS NOT NULL
AND TRIM(parent.thread_key) != ''
AND (
-- Match via in_reply_to -> parent message_id
(
child.in_reply_to IS NOT NULL
AND TRIM(child.in_reply_to) != ''
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
= LOWER(REGEXP_REPLACE(
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.in_reply_to), E'[\\s,]+'))[1],
'[<>\s]', '', 'g'
))
)
OR
-- Match via first reference -> parent message_id
(
child.email_references IS NOT NULL
AND TRIM(child.email_references) != ''
AND LOWER(REGEXP_REPLACE(parent.message_id, '[<>\s]', '', 'g'))
= LOWER(REGEXP_REPLACE(
(REGEXP_SPLIT_TO_ARRAY(TRIM(child.email_references), E'[\\s,]+'))[1],
'[<>\s]', '', 'g'
))
)
);
-- For emails that are conversation starters (no in_reply_to, no references),
-- reset thread_key to their own message_id so they start their own thread.
UPDATE email_messages
SET thread_key = LOWER(REGEXP_REPLACE(COALESCE(message_id, ''), '[<>\s]', '', 'g')),
updated_at = CURRENT_TIMESTAMP
WHERE deleted_at IS NULL
AND (in_reply_to IS NULL OR TRIM(in_reply_to) = '')
AND (email_references IS NULL OR TRIM(email_references) = '')
AND message_id IS NOT NULL
AND TRIM(message_id) != '';

View File

@ -0,0 +1,32 @@
-- Migration 158: SAG work-order scan tokens and file provenance
-- Enables token-based auto-linking of scanned documents to cases.
CREATE TABLE IF NOT EXISTS sag_document_tokens (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
token VARCHAR(120) NOT NULL UNIQUE,
token_type VARCHAR(40) NOT NULL,
hardware_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
expires_at TIMESTAMP,
consumed_at TIMESTAMP,
consumed_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT sag_document_tokens_type_check CHECK (token_type IN ('work_order', 'hardware_label'))
);
CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_sag_id ON sag_document_tokens(sag_id);
CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_token_type ON sag_document_tokens(token_type);
CREATE INDEX IF NOT EXISTS idx_sag_document_tokens_consumed ON sag_document_tokens(consumed_at);
ALTER TABLE sag_files
ADD COLUMN IF NOT EXISTS source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS source_type VARCHAR(40),
ADD COLUMN IF NOT EXISTS source_token VARCHAR(120);
UPDATE sag_files
SET source_type = 'upload'
WHERE source_type IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_files_source_email_id ON sag_files(source_email_id);
CREATE INDEX IF NOT EXISTS idx_sag_files_source_token ON sag_files(source_token);

View File

@ -20,3 +20,6 @@ APScheduler==3.10.4
pdfplumber==0.11.4 pdfplumber==0.11.4
av==13.1.0 av==13.1.0
Pillow==11.0.0 Pillow==11.0.0
brother_ql==0.9.4
pyzbar==0.1.9
pypdfium2==4.30.0

134
tmp/links_import.sql Normal file
View File

@ -0,0 +1,134 @@
BEGIN;
CREATE TEMP TABLE tmp_links_import (
name TEXT NOT NULL,
url TEXT NOT NULL,
category_name TEXT NOT NULL
);
INSERT INTO tmp_links_import (name, url, category_name) VALUES
('Guacamole','https://rdp-dash.bmcnetworks.dk/guacamole/#/','Interne systemer (Admin)'),
('MailChimp','https://login.mailchimp.com','Interne systemer (Admin)'),
('Plesk','https://isp.bmcnetworks.dk:8443/login_up.php','Interne systemer (Admin)'),
('Speedtest (admin)','http://speedtest.bmcnetworks.dk/results/stats.php','Interne systemer (Admin)'),
('Uisp','https://uisp.bmcnetworks.dk','Interne systemer (Admin)'),
('s3 Admin','http://172.16.30.13:9001','Interne systemer (Admin)'),
('Mailarkiv admin','https://arkiv.bmcmailarkiv.dk/','Interne systemer (Admin)'),
('Ducky Mail admin','https://mailadmin.bmcdenmark.com','BMC Mail server'),
('Webmail','https://mail.bmcdenmark.com','BMC Mail server'),
('BMC Anydesk','https://get.anydesk.com/0RRDdvHP/BMCsupport.exe','Public links'),
('Ninite Std software','https://ninite.com/.net4.8-.net5-.net6-.net7-.netx5-.netx6-.netx7-adoptjava8-adoptjavax11-adoptjavax17-adoptjavax8-firefox-vlc/ninite.exe','Public links'),
('Norva24 Nextcloud','https://norva24tv.acdu.dk/login','Norva24'),
('SFTP Nextcloud liste','https://bmcdenmark.sharepoint.com','Norva24'),
('Maskinsikkerhed Nextcloud','https://ms.docs.bmcnetworks.dk/login?redirect_url=/apps/dashboard/','Maskinsikkerhed'),
('Android Kiosk','https://downloads.pronestor.com','PFA'),
('Anydesk PFA','https://my.anydesk.com','PFA'),
('Clickshare barco','http://xms.cloud.barco.com','PFA'),
('Clickshare guide','https://theunion.dk','PFA'),
('Meraki PFA','https://n717.meraki.com','PFA'),
('The Union Planner','https://the-union.pronestor.com','PFA'),
('care.oniadea','https://care.oniadea.com','PFA'),
('BMC Nextcloud','https://nc.bmcnetworks.dk','Interne systemer'),
('BMC Sharepoint','https://bmcdenmark.sharepoint.com','Interne systemer'),
('2fAuth','https://2f.bmcnetworks.dk/','Interne systemer'),
('Seafile','https://docs.bmcnetworks.dk','Interne systemer'),
('Vaultwarden','https://bw.bmcnetworks.dk/#/','Interne systemer'),
('BMC mail arkiv','https://bmcnetworks.bmcmailarkiv.dk','Interne systemer'),
('Uptime Kuma','https://kuma.bmcnetworks.dk/dashboard','Interne systemer'),
('uISP OLD','https://unms-pri.bmcnetworks.dk','Interne systemer'),
('Smokeping','https://smokeping.bmcnetworks.dk','Interne systemer'),
('Teknik WIKI','https://wiki.bmcnetworks.dk','Interne systemer'),
('Unifi','https://unifi.bmcnetworks.dk:8443','Interne systemer'),
('Unifi old','https://unifi-sdn.bmcnetworks.dk:8443/','Interne systemer'),
('BMC Office install','http://software.bmcnetworks.dk','Externe systemer'),
('BMC Speakonline','https://phone-wizard.com','Externe systemer'),
('Cloudfactory Portal','http://portal.cloudfactory.dk','Externe systemer'),
('Eset MSP','https://msp.eset.com','Externe systemer'),
('Minside Telefoni','https://minside.bmcnetworks.dk','Externe systemer'),
('My Globalconnect','https://my.globalconnect.dk','Externe systemer'),
('SentinelOne','https://euce1-teamblue.sentinelone.net','Externe systemer'),
('Simply CRM portal','https://tickets.simply-crm.com','Externe systemer'),
('Globalconnect','https://nn.globalconnect.dk','Externe systemer'),
('Simply CRM','https://bmcnetworks.simply-crm.dk','Externe systemer'),
('Portal admin','https://mit.bmcnetworks.dk','Externe systemer'),
('Jira','https://bmcdenmark.atlassian.net','Externe systemer'),
('Avast hub','http://businesshub.avast.com','Externe systemer'),
('Booking mødelokale','https://3048.torvekoekken.dk','Externe systemer'),
('CP SMS','https://www.cpsms.dk','Externe systemer'),
('Curanet','https://reseller.curanet.dk','Externe systemer'),
('Mit GC','https://nn.globalconnect.dk','Externe systemer'),
('Shipmondo','https://app.shipmondo.com','Externe systemer'),
('e-conomic','https://secure.e-conomic.com','Externe systemer'),
('Provision Yealink','https://dm.yealink.com','Externe systemer'),
('Carl-Ras','https://www.carl-ras.dk','Grosister'),
('Deltaco','https://www.deltaco.dk','Grosister'),
('Serverschmiede','https://www.serverschmiede.com','Grosister'),
('DCS','http://dcs.dk','Grosister'),
('Also','https://www.also.com','Grosister'),
('EET','https://www.eetgroup.com','Grosister'),
('Farnell','https://dk.farnell.com','Grosister'),
('Lemvigh-Müller','https://www.lemu.dk','Grosister'),
('Lan-Com','https://lan-com.dk','Grosister'),
('Clerk','https://my.clerk.io','ITvarer.dk'),
('OnPay Manager','https://manage.onpay.io','ITvarer.dk'),
('Stedger','https://dashboard.stedger.com','ITvarer.dk'),
('Webshop admin','https://itvarer.bmcnetworks.dk','ITvarer.dk'),
('3 Erhverv','https://www.3.dk','Tele sites'),
('ICH','http://ich01.supertel.dk','Tele sites'),
('Mastedatabasen','https://www.mastedatabasen.dk','Tele sites'),
('BMCnas','https://172.16.20.28/cgi-bin/','Hardware'),
('HP Officejet','http://172.16.20.187','Hardware'),
('TrueNAS','https://172.16.30.9','Hardware'),
('Flame search tips','https://github.com/pawelmalak/flame/wiki/Search-bar','Diverse'),
('Mentech','http://mentech.dk','Diverse'),
('BMCnet.dk','http://bmcnet.dk','bmcnet.dk'),
('bmcnet admin','https://reseller.curanet.dk','bmcnet.dk'),
('Power DNS','http://172.16.20.25','Old links'),
('SugarCRM','http://sugar.intranet.bmc','Old links'),
('Teknik intra','http://teknik.intranet.bmc','Old links');
INSERT INTO link_categories (name, icon, sort_order)
SELECT DISTINCT category_name, 'bi-link-45deg', 100
FROM tmp_links_import
ON CONFLICT (name) DO NOTHING;
INSERT INTO links (name, type, url, environment, is_critical, is_favorite)
SELECT t.name, 'http', t.url, 'prod', FALSE, FALSE
FROM tmp_links_import t
WHERE NOT EXISTS (
SELECT 1
FROM links l
WHERE l.deleted_at IS NULL
AND l.name = t.name
AND l.url = t.url
);
INSERT INTO link_category_map (link_id, category_id)
SELECT l.id, c.id
FROM tmp_links_import t
JOIN link_categories c ON c.name = t.category_name
JOIN LATERAL (
SELECT id
FROM links
WHERE deleted_at IS NULL
AND name = t.name
AND url = t.url
ORDER BY id ASC
LIMIT 1
) l ON TRUE
ON CONFLICT DO NOTHING;
COMMIT;
SELECT
(SELECT COUNT(*) FROM tmp_links_import) AS source_rows,
(SELECT COUNT(*) FROM link_categories WHERE name IN (SELECT DISTINCT category_name FROM tmp_links_import)) AS matched_categories,
(SELECT COUNT(*) FROM links WHERE deleted_at IS NULL AND (name, url) IN (SELECT name, url FROM tmp_links_import)) AS matched_links,
(SELECT COUNT(*)
FROM link_category_map lcm
JOIN links l ON l.id = lcm.link_id
JOIN link_categories c ON c.id = lcm.category_id
WHERE l.deleted_at IS NULL
AND (l.name, l.url) IN (SELECT name, url FROM tmp_links_import)
AND c.name IN (SELECT DISTINCT category_name FROM tmp_links_import)
) AS matched_mappings;