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:
parent
bc504b9257
commit
30d1be61eb
@ -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=
|
||||||
|
|||||||
@ -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=
|
||||||
|
|||||||
@ -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 \
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 = ""
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
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 =
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
@ -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."""
|
||||||
|
|||||||
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}
|
||||||
|
|||||||
263
app/services/brother_label_print_service.py
Normal file
263
app/services/brother_label_print_service.py
Normal 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
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
185
app/services/vaultwarden_service.py
Normal file
185
app/services/vaultwarden_service.py
Normal 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,
|
||||||
|
}
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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
20
check_threads.sql
Normal 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;
|
||||||
@ -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}
|
||||||
|
|||||||
48
migrations/156_backfill_email_thread_keys.sql
Normal file
48
migrations/156_backfill_email_thread_keys.sql
Normal 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.
|
||||||
57
migrations/157_fix_thread_keys_multi_thread.sql
Normal file
57
migrations/157_fix_thread_keys_multi_thread.sql
Normal 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) != '';
|
||||||
32
migrations/158_sag_work_orders_and_scan_tokens.sql
Normal file
32
migrations/158_sag_work_orders_and_scan_tokens.sql
Normal 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);
|
||||||
@ -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
134
tmp/links_import.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user