Release: mission day workflow, telefoni contact modal, fedex support overview, and economic sync dry-run
This commit is contained in:
parent
f2c8af4680
commit
5ee962fdb3
@ -17,13 +17,58 @@
|
|||||||
.search-wrap {
|
.search-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
max-width: 460px;
|
max-width: 520px;
|
||||||
width: min(46vw, 460px);
|
width: min(52vw, 520px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-shell {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.28);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 76, 117, 0.09) 0%, rgba(15, 76, 117, 0.04) 100%);
|
||||||
|
box-shadow: 0 8px 20px rgba(2, 32, 71, 0.1);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-shell:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.18), 0 10px 24px rgba(2, 32, 71, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.8rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-wrap .header-search {
|
.search-wrap .header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding-left: 2.25rem;
|
||||||
padding-right: 2.4rem;
|
padding-right: 2.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap .header-search::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-clear {
|
.search-clear {
|
||||||
@ -68,6 +113,44 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-utility-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-toggle-btn {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.2);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-toggle-btn:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-toggle-menu {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 10px 24px rgba(2, 32, 71, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-toggle-item {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-toggle-item:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.contacts-shell {
|
.contacts-shell {
|
||||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@ -117,6 +200,41 @@
|
|||||||
box-shadow: 0 1px 0 rgba(15, 76, 117, 0.12);
|
box-shadow: 0 1px 0 rgba(15, 76, 117, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn .sort-indicator {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn.active .sort-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-shell .table thead th.col-key {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-shell .table thead th.col-updated {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-name {
|
.contact-name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@ -138,17 +256,63 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin-top: 0.18rem;
|
margin-top: 0.08rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-quick-actions .btn {
|
.contact-quick-actions .btn {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.08rem 0.52rem;
|
padding: 0.04rem 0.45rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.69rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-data-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-line {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
min-height: 1.2rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-label {
|
||||||
|
font-size: 0.61rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.08rem 0.35rem;
|
||||||
|
background: rgba(15, 76, 117, 0.08);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-value:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-data {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.79rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated-at {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.company-count-chip {
|
.company-count-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -354,10 +518,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-search-slot">
|
<div class="toolbar-search-slot">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<input type="search" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon eller firma..." autocomplete="off" spellcheck="false">
|
<div class="search-label"><i class="bi bi-search"></i>Hurtig søgning</div>
|
||||||
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
|
<div class="search-input-shell">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-search search-icon"></i>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||||
@ -378,21 +546,67 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4 contacts-shell">
|
<div class="card p-4 contacts-shell">
|
||||||
|
<div class="table-utility-bar">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="column-toggle-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-layout-three-columns me-1"></i>Kolonner
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end column-toggle-menu" id="columnToggleMenu">
|
||||||
|
<label class="dropdown-item column-toggle-item">
|
||||||
|
<input class="form-check-input me-2 column-toggle-input" type="checkbox" data-column="title" checked>
|
||||||
|
Titel
|
||||||
|
</label>
|
||||||
|
<label class="dropdown-item column-toggle-item">
|
||||||
|
<input class="form-check-input me-2 column-toggle-input" type="checkbox" data-column="companies" checked>
|
||||||
|
Firmaer
|
||||||
|
</label>
|
||||||
|
<label class="dropdown-item column-toggle-item">
|
||||||
|
<input class="form-check-input me-2 column-toggle-input" type="checkbox" data-column="updated" checked>
|
||||||
|
Opdateret
|
||||||
|
</label>
|
||||||
|
<label class="dropdown-item column-toggle-item">
|
||||||
|
<input class="form-check-input me-2 column-toggle-input" type="checkbox" data-column="status" checked>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive contacts-table-wrap">
|
<div class="table-responsive contacts-table-wrap">
|
||||||
<table class="table table-hover align-middle contacts-table">
|
<table class="table table-hover align-middle contacts-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Navn</th>
|
<th>
|
||||||
<th>Kontakt Info</th>
|
<button type="button" class="sort-btn" data-sort-key="name" onclick="setSort('name')">
|
||||||
<th>Titel</th>
|
Navn <i class="bi bi-arrow-down-up sort-indicator"></i>
|
||||||
<th>Firmaer</th>
|
</button>
|
||||||
<th>Status</th>
|
</th>
|
||||||
|
<th class="col-key">Vigtig Info</th>
|
||||||
|
<th class="col-title">
|
||||||
|
<button type="button" class="sort-btn" data-sort-key="title" onclick="setSort('title')">
|
||||||
|
Titel <i class="bi bi-arrow-down-up sort-indicator"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="col-companies">
|
||||||
|
<button type="button" class="sort-btn" data-sort-key="company_count" onclick="setSort('company_count')">
|
||||||
|
Firmaer <i class="bi bi-arrow-down-up sort-indicator"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="col-updated">
|
||||||
|
<button type="button" class="sort-btn" data-sort-key="updated_at" onclick="setSort('updated_at')">
|
||||||
|
Opdateret <i class="bi bi-arrow-down-up sort-indicator"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="col-status">
|
||||||
|
<button type="button" class="sort-btn" data-sort-key="is_active" onclick="setSort('is_active')">
|
||||||
|
Status <i class="bi bi-arrow-down-up sort-indicator"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th class="text-end">Handlinger</th>
|
<th class="text-end">Handlinger</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="contactsTableBody">
|
<tbody id="contactsTableBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center py-5">
|
<td colspan="7" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@ -602,9 +816,23 @@ let currentRequestController = null;
|
|||||||
let lastLoadedQueryKey = '';
|
let lastLoadedQueryKey = '';
|
||||||
let availableCompanies = [];
|
let availableCompanies = [];
|
||||||
let selectedCompanyIds = new Set();
|
let selectedCompanyIds = new Set();
|
||||||
|
let currentContactsData = [];
|
||||||
|
let currentSort = {
|
||||||
|
key: 'name',
|
||||||
|
direction: 'asc'
|
||||||
|
};
|
||||||
|
let visibleColumns = {
|
||||||
|
title: true,
|
||||||
|
companies: true,
|
||||||
|
updated: true,
|
||||||
|
status: true
|
||||||
|
};
|
||||||
|
|
||||||
// Load contacts on page load
|
// Load contacts on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadTablePreferences();
|
||||||
|
applyColumnVisibility();
|
||||||
|
updateSortIndicators();
|
||||||
loadContacts();
|
loadContacts();
|
||||||
loadCompaniesForSelect();
|
loadCompaniesForSelect();
|
||||||
|
|
||||||
@ -663,6 +891,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('companySearchInput')?.addEventListener('input', (e) => {
|
document.getElementById('companySearchInput')?.addEventListener('input', (e) => {
|
||||||
renderCompanyResults(e.target.value || '');
|
renderCompanyResults(e.target.value || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.column-toggle-input').forEach((input) => {
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
const column = e.target.getAttribute('data-column');
|
||||||
|
visibleColumns[column] = !!e.target.checked;
|
||||||
|
persistTablePreferences();
|
||||||
|
applyColumnVisibility();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setFilter(filter) {
|
function setFilter(filter) {
|
||||||
@ -680,7 +917,7 @@ function setFilter(filter) {
|
|||||||
|
|
||||||
async function loadContacts() {
|
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="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
||||||
|
|
||||||
if (currentRequestController) {
|
if (currentRequestController) {
|
||||||
currentRequestController.abort();
|
currentRequestController.abort();
|
||||||
@ -714,7 +951,8 @@ async function loadContacts() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
totalContacts = data.total;
|
totalContacts = data.total;
|
||||||
displayContacts(data.contacts);
|
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
|
||||||
|
displayContacts(currentContactsData);
|
||||||
updatePagination(data.total);
|
updatePagination(data.total);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -722,7 +960,7 @@ async function loadContacts() {
|
|||||||
return;
|
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="7" class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</td></tr>';
|
||||||
} finally {
|
} finally {
|
||||||
currentRequestController = null;
|
currentRequestController = null;
|
||||||
}
|
}
|
||||||
@ -734,13 +972,16 @@ function toggleClearButton(value) {
|
|||||||
|
|
||||||
function displayContacts(contacts) {
|
function displayContacts(contacts) {
|
||||||
const tbody = document.getElementById('contactsTableBody');
|
const tbody = document.getElementById('contactsTableBody');
|
||||||
|
const sortedContacts = getSortedContacts(contacts);
|
||||||
|
|
||||||
if (!contacts || contacts.length === 0) {
|
if (!sortedContacts || sortedContacts.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-muted">Ingen kontakter fundet</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-muted">Ingen kontakter fundet</td></tr>';
|
||||||
|
applyColumnVisibility();
|
||||||
|
updateSortIndicators();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = contacts.map(contact => {
|
tbody.innerHTML = sortedContacts.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="status-pill active">Aktiv</span>'
|
? '<span class="status-pill active">Aktiv</span>'
|
||||||
@ -752,23 +993,16 @@ function displayContacts(contacts) {
|
|||||||
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
|
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
|
||||||
: '-';
|
: '-';
|
||||||
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
|
||||||
const mobileLine = contact.mobile
|
const preferredPhone = contact.mobile || contact.phone || '';
|
||||||
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.mobile)}
|
const hasEmail = !!contact.email;
|
||||||
<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.mobile)}')">Ring op</button>
|
const hasPreferredPhone = !!preferredPhone;
|
||||||
<button class="btn btn-sm btn-outline-primary py-0 px-2" onclick="event.stopPropagation(); openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(fullName)}', ${contact.id || 'null'})">SMS</button>
|
|
||||||
</div>`
|
|
||||||
: '';
|
|
||||||
const phoneLine = !contact.mobile
|
|
||||||
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.phone || '-')}
|
|
||||||
${contact.phone ? `<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.phone)}')">Ring op</button>` : ''}
|
|
||||||
</div>`
|
|
||||||
: '';
|
|
||||||
const smsLine = mobileLine || phoneLine;
|
|
||||||
const safeName = escapeHtml(`${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '-');
|
const safeName = escapeHtml(`${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '-');
|
||||||
const safeDepartment = escapeHtml(contact.department || '-');
|
const safeDepartment = escapeHtml(contact.department || '-');
|
||||||
const safeEmail = escapeHtml(contact.email || '-');
|
const safeEmail = escapeHtml(contact.email || '-');
|
||||||
const safeTitle = escapeHtml(contact.title || '-');
|
const safeTitle = escapeHtml(contact.title || '-');
|
||||||
|
const safePhone = escapeHtml(preferredPhone || '-');
|
||||||
const companiesTitle = escapeHtml(companyNames.join(', '));
|
const companiesTitle = escapeHtml(companyNames.join(', '));
|
||||||
|
const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr onclick="viewContact(${contact.id})">
|
<tr onclick="viewContact(${contact.id})">
|
||||||
@ -782,17 +1016,38 @@ function displayContacts(contacts) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="contact-info-main">${safeEmail}</div>
|
<div class="contact-data-stack">
|
||||||
<div class="contact-quick-actions">${smsLine}</div>
|
<div class="key-line">
|
||||||
|
<span class="key-label">Email</span>
|
||||||
|
${hasEmail
|
||||||
|
? `<a class="key-value" href="mailto:${safeEmail}" onclick="event.stopPropagation()">${safeEmail}</a>`
|
||||||
|
: '<span class="muted-data">Ikke angivet</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="key-line">
|
||||||
|
<span class="key-label">Telefon</span>
|
||||||
|
<span class="key-value">${safePhone}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-quick-actions">
|
||||||
|
${hasPreferredPhone
|
||||||
|
? `<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(preferredPhone)}')">Ring op</button>`
|
||||||
|
: ''}
|
||||||
|
${contact.mobile
|
||||||
|
? `<button class="btn btn-sm btn-outline-primary py-0 px-2" onclick="event.stopPropagation(); openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(fullName)}', ${contact.id || 'null'})">SMS</button>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted">${safeTitle}</td>
|
<td class="text-muted col-title">${safeTitle}</td>
|
||||||
<td>
|
<td class="col-companies">
|
||||||
<span class="company-count-chip" title="${companiesTitle}">
|
<span class="company-count-chip" title="${companiesTitle}">
|
||||||
<i class="bi bi-building"></i>${companyCount}
|
<i class="bi bi-building"></i>${companyCount}
|
||||||
</span>
|
</span>
|
||||||
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
|
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>${statusBadge}</td>
|
<td class="col-updated">
|
||||||
|
<span class="updated-at">${updatedAt}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-status">${statusBadge}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); viewContact(${contact.id})" title="Vis kontakt">
|
<button class="btn btn-sm btn-table-action" onclick="event.stopPropagation(); viewContact(${contact.id})" title="Vis kontakt">
|
||||||
@ -806,6 +1061,125 @@ function displayContacts(contacts) {
|
|||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
applyColumnVisibility();
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSort(key) {
|
||||||
|
if (currentSort.key === key) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.key = key;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
persistTablePreferences();
|
||||||
|
displayContacts(currentContactsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedContacts(contacts) {
|
||||||
|
const list = Array.isArray(contacts) ? [...contacts] : [];
|
||||||
|
const direction = currentSort.direction === 'desc' ? -1 : 1;
|
||||||
|
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
const key = currentSort.key;
|
||||||
|
|
||||||
|
if (key === 'company_count') {
|
||||||
|
return ((Number(a.company_count) || 0) - (Number(b.company_count) || 0)) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'updated_at') {
|
||||||
|
const dateA = new Date(a.updated_at || a.created_at || 0).getTime() || 0;
|
||||||
|
const dateB = new Date(b.updated_at || b.created_at || 0).getTime() || 0;
|
||||||
|
return (dateA - dateB) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'is_active') {
|
||||||
|
return ((a.is_active ? 1 : 0) - (b.is_active ? 1 : 0)) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'title') {
|
||||||
|
const titleA = String(a.title || '').toLowerCase();
|
||||||
|
const titleB = String(b.title || '').toLowerCase();
|
||||||
|
return titleA.localeCompare(titleB, 'da') * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameA = `${a.last_name || ''} ${a.first_name || ''}`.trim().toLowerCase();
|
||||||
|
const nameB = `${b.last_name || ''} ${b.first_name || ''}`.trim().toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB, 'da') * direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIndicators() {
|
||||||
|
document.querySelectorAll('.sort-btn').forEach((btn) => {
|
||||||
|
const key = btn.getAttribute('data-sort-key');
|
||||||
|
const icon = btn.querySelector('.sort-indicator');
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
btn.classList.remove('active');
|
||||||
|
icon.className = 'bi bi-arrow-down-up sort-indicator';
|
||||||
|
|
||||||
|
if (key === currentSort.key) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
icon.className = currentSort.direction === 'asc'
|
||||||
|
? 'bi bi-sort-down-alt sort-indicator'
|
||||||
|
: 'bi bi-sort-up-alt sort-indicator';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColumnVisibility() {
|
||||||
|
toggleColumnClass('col-title', visibleColumns.title);
|
||||||
|
toggleColumnClass('col-companies', visibleColumns.companies);
|
||||||
|
toggleColumnClass('col-updated', visibleColumns.updated);
|
||||||
|
toggleColumnClass('col-status', visibleColumns.status);
|
||||||
|
|
||||||
|
document.querySelectorAll('.column-toggle-input').forEach((input) => {
|
||||||
|
const key = input.getAttribute('data-column');
|
||||||
|
input.checked = !!visibleColumns[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleColumnClass(columnClass, isVisible) {
|
||||||
|
document.querySelectorAll(`.${columnClass}`).forEach((el) => {
|
||||||
|
el.classList.toggle('d-none', !isVisible);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTablePreferences() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('contactsTablePrefsV1');
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (parsed && parsed.visibleColumns) {
|
||||||
|
visibleColumns = {
|
||||||
|
...visibleColumns,
|
||||||
|
...parsed.visibleColumns
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && parsed.currentSort && parsed.currentSort.key) {
|
||||||
|
currentSort = {
|
||||||
|
key: parsed.currentSort.key,
|
||||||
|
direction: parsed.currentSort.direction === 'desc' ? 'desc' : 'asc'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Kunne ikke indlæse tabel-indstillinger', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistTablePreferences() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('contactsTablePrefsV1', JSON.stringify({
|
||||||
|
visibleColumns,
|
||||||
|
currentSort
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Kunne ikke gemme tabel-indstillinger', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePagination(total) {
|
function updatePagination(total) {
|
||||||
@ -1114,6 +1488,17 @@ function getInitials(firstName, lastName) {
|
|||||||
return (first + last).toUpperCase();
|
return (first + last).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatContactDate(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '-';
|
||||||
|
return parsed.toLocaleDateString('da-DK', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
|||||||
@ -326,6 +326,217 @@ class MissionService:
|
|||||||
result.append(item)
|
result.append(item)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]:
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', u.user_id::text)) AS display_name
|
||||||
|
FROM users u
|
||||||
|
WHERE COALESCE(u.is_active, TRUE) = TRUE
|
||||||
|
ORDER BY display_name ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
) or []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": int(row.get("user_id") or 0),
|
||||||
|
"display_name": row.get("display_name") or "Ukendt",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
if row.get("user_id") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_assignment_groups(limit: int = 200) -> list[Dict[str, Any]]:
|
||||||
|
if not MissionService._table_exists("groups"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, COALESCE(NULLIF(TRIM(name), ''), CONCAT('Gruppe #', id::text)) AS name
|
||||||
|
FROM groups
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
) or []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": int(row.get("id") or 0),
|
||||||
|
"name": row.get("name") or "Ukendt gruppe",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
if row.get("id") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_day_unassigned_cases(limit: int = 120) -> list[Dict[str, Any]]:
|
||||||
|
if not MissionService._table_exists("sag_sager"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.beskrivelse,
|
||||||
|
s.status,
|
||||||
|
s.priority,
|
||||||
|
s.start_date,
|
||||||
|
s.deadline,
|
||||||
|
s.created_at,
|
||||||
|
s.ansvarlig_bruger_id,
|
||||||
|
s.assigned_group_id,
|
||||||
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS ansvarlig_navn,
|
||||||
|
COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS assigned_group_name,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
||||||
|
AND (s.ansvarlig_bruger_id IS NULL OR s.assigned_group_id IS NULL)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN s.ansvarlig_bruger_id IS NULL AND s.assigned_group_id IS NULL THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END ASC,
|
||||||
|
CASE LOWER(COALESCE(s.priority::text, ''))
|
||||||
|
WHEN 'kritisk' THEN 5
|
||||||
|
WHEN 'critical' THEN 5
|
||||||
|
WHEN 'høj' THEN 4
|
||||||
|
WHEN 'hoj' THEN 4
|
||||||
|
WHEN 'high' THEN 4
|
||||||
|
WHEN 'urgent' THEN 4
|
||||||
|
WHEN 'medium' THEN 3
|
||||||
|
WHEN 'normal' THEN 2
|
||||||
|
WHEN 'lav' THEN 1
|
||||||
|
WHEN 'low' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END DESC,
|
||||||
|
s.deadline ASC NULLS LAST,
|
||||||
|
s.created_at ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
) or []
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_day_agent_workloads(limit_agents: int = 60, limit_cases_per_agent: int = 20) -> list[Dict[str, Any]]:
|
||||||
|
if not MissionService._table_exists("sag_sager"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
WITH active_cases AS (
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.titel,
|
||||||
|
s.status,
|
||||||
|
s.priority,
|
||||||
|
s.start_date,
|
||||||
|
s.deadline,
|
||||||
|
s.created_at,
|
||||||
|
COALESCE(c.name, 'Ukendt kunde') AS customer_name,
|
||||||
|
s.ansvarlig_bruger_id,
|
||||||
|
s.assigned_group_id,
|
||||||
|
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS assignee_name,
|
||||||
|
COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS group_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND LOWER(COALESCE(s.status, '')) <> 'afsluttet'
|
||||||
|
AND (
|
||||||
|
(s.start_date IS NOT NULL AND s.start_date::date <= CURRENT_DATE)
|
||||||
|
OR
|
||||||
|
(s.deadline IS NOT NULL AND s.deadline::date <= CURRENT_DATE)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
grouped AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text)
|
||||||
|
WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text)
|
||||||
|
ELSE 'unassigned'
|
||||||
|
END,
|
||||||
|
'unassigned'
|
||||||
|
) AS assignee_key,
|
||||||
|
COALESCE(assignee_name, group_name, 'Ufordelt') AS assignee_name,
|
||||||
|
COUNT(*) AS total_cases,
|
||||||
|
COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_cases,
|
||||||
|
COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS due_today_cases,
|
||||||
|
COUNT(*) FILTER (WHERE start_date IS NOT NULL AND start_date::date <= CURRENT_DATE) AS started_cases,
|
||||||
|
JSONB_AGG(
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', id,
|
||||||
|
'titel', titel,
|
||||||
|
'status', status,
|
||||||
|
'priority', priority,
|
||||||
|
'customer_name', customer_name,
|
||||||
|
'start_date', start_date,
|
||||||
|
'deadline', deadline,
|
||||||
|
'created_at', created_at
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN deadline IS NOT NULL AND deadline::date < CURRENT_DATE THEN 0
|
||||||
|
WHEN deadline IS NOT NULL AND deadline::date = CURRENT_DATE THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
deadline ASC NULLS LAST,
|
||||||
|
created_at ASC
|
||||||
|
) AS case_list
|
||||||
|
FROM active_cases
|
||||||
|
GROUP BY assignee_key, assignee_name
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
assignee_key,
|
||||||
|
assignee_name,
|
||||||
|
total_cases,
|
||||||
|
overdue_cases,
|
||||||
|
due_today_cases,
|
||||||
|
started_cases,
|
||||||
|
CASE
|
||||||
|
WHEN case_list IS NULL THEN '[]'::jsonb
|
||||||
|
ELSE case_list
|
||||||
|
END AS case_list
|
||||||
|
FROM grouped
|
||||||
|
ORDER BY overdue_cases DESC, due_today_cases DESC, total_cases DESC, assignee_name ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit_agents,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
result: list[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
case_list = row.get("case_list")
|
||||||
|
if isinstance(case_list, list):
|
||||||
|
trimmed_cases = case_list[: max(1, int(limit_cases_per_agent))]
|
||||||
|
else:
|
||||||
|
trimmed_cases = []
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"assignee_key": row.get("assignee_key") or "unassigned",
|
||||||
|
"assignee_name": row.get("assignee_name") or "Ufordelt",
|
||||||
|
"total_cases": int(row.get("total_cases") or 0),
|
||||||
|
"overdue_cases": int(row.get("overdue_cases") or 0),
|
||||||
|
"due_today_cases": int(row.get("due_today_cases") or 0),
|
||||||
|
"started_cases": int(row.get("started_cases") or 0),
|
||||||
|
"case_list": trimmed_cases,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]:
|
def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]:
|
||||||
if not MissionService._table_exists("email_messages"):
|
if not MissionService._table_exists("email_messages"):
|
||||||
@ -392,6 +603,10 @@ class MissionService:
|
|||||||
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
|
||||||
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
|
||||||
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
|
"important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []),
|
||||||
|
"day_unassigned_cases": MissionService._safe("day_unassigned_cases", lambda: MissionService.get_day_unassigned_cases(120), []),
|
||||||
|
"day_agent_workloads": MissionService._safe("day_agent_workloads", lambda: MissionService.get_day_agent_workloads(60, 20), []),
|
||||||
|
"assignment_users": MissionService._safe("assignment_users", lambda: MissionService.get_assignment_users(300), []),
|
||||||
|
"assignment_groups": MissionService._safe("assignment_groups", lambda: MissionService.get_assignment_groups(200), []),
|
||||||
"recent_emails": MissionService._safe("recent_emails", lambda: MissionService.get_recent_emails(25), []),
|
"recent_emails": MissionService._safe("recent_emails", lambda: MissionService.get_recent_emails(25), []),
|
||||||
"environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []),
|
"environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []),
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
@ -78,9 +78,19 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-controls input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-controls input[type="range"] {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
.mc-nav {
|
.mc-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,6 +104,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
transition: 0.15s ease;
|
transition: 0.15s ease;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-nav-btn.active {
|
.mc-nav-btn.active {
|
||||||
@ -126,6 +137,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-chip.active {
|
.mc-chip.active {
|
||||||
@ -389,6 +401,7 @@
|
|||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
min-width: 54px;
|
min-width: 54px;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-duration-btn.active {
|
.mc-duration-btn.active {
|
||||||
@ -493,6 +506,233 @@
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-day-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-tab {
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.3rem 0.78rem;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-tab.active {
|
||||||
|
color: #dff3ff;
|
||||||
|
border-color: #77b6df;
|
||||||
|
background: var(--mc-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-case-card {
|
||||||
|
border: 1px solid rgba(157, 181, 210, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.65rem 0.72rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-case-title {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-case-sub {
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-top: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-case-desc {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #d9ebff;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-assign-row {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-select {
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--mc-text);
|
||||||
|
padding: 0.36rem 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-btn {
|
||||||
|
border: 1px solid rgba(126, 194, 239, 0.45);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(15, 76, 117, 0.45);
|
||||||
|
color: #dff3ff;
|
||||||
|
font-size: 0.81rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.35rem 0.62rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.mc-shell {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-card {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-subtle {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-controls {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-controls label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-controls input[type="checkbox"] {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-controls input[type="range"] {
|
||||||
|
min-width: 180px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-nav-btn {
|
||||||
|
min-height: 72px;
|
||||||
|
font-size: 1.14rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-tab,
|
||||||
|
.mc-chip {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-select {
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-btn,
|
||||||
|
.mc-duration-btn {
|
||||||
|
min-height: 46px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-case-title,
|
||||||
|
.mc-day-case-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-case-sub,
|
||||||
|
.mc-day-case-sub,
|
||||||
|
.mc-feed-meta {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-agents {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-agent-card {
|
||||||
|
border: 1px solid rgba(157, 181, 210, 0.22);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.62rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-agent-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-agent-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-agent-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.32rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-mini {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.17rem 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-mini.alert {
|
||||||
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
|
color: #ffd0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-mini.warn {
|
||||||
|
border-color: rgba(245, 158, 11, 0.5);
|
||||||
|
color: #ffdb9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-agent-cases {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.32rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mc-day-agent-case {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(157, 181, 210, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mc-feed-title {
|
.mc-feed-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@ -547,6 +787,10 @@
|
|||||||
.mc-row-head {
|
.mc-row-head {
|
||||||
grid-template-columns: 1.7fr 1fr 1fr 1fr;
|
grid-template-columns: 1.7fr 1fr 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-day-agents {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@ -564,6 +808,10 @@
|
|||||||
min-height: min(70vh, 720px);
|
min-height: min(70vh, 720px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mc-day-assign-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.mc-email-row {
|
.mc-email-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@ -599,16 +847,13 @@
|
|||||||
|
|
||||||
<div class="mc-nav" id="missionNav">
|
<div class="mc-nav" id="missionNav">
|
||||||
<button class="mc-nav-btn active" type="button" data-view="overview">Overblik</button>
|
<button class="mc-nav-btn active" type="button" data-view="overview">Overblik</button>
|
||||||
|
<button class="mc-nav-btn" type="button" data-view="day">Dagen</button>
|
||||||
<button class="mc-nav-btn" type="button" data-view="important">Vigtige sager</button>
|
<button class="mc-nav-btn" type="button" data-view="important">Vigtige sager</button>
|
||||||
<button class="mc-nav-btn" type="button" data-view="calls">Opkald</button>
|
<button class="mc-nav-btn" type="button" data-view="calls">Opkald</button>
|
||||||
<button class="mc-nav-btn" type="button" data-view="camera">Kamera</button>
|
<button class="mc-nav-btn" type="button" data-view="camera">Kamera</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mc-card">
|
|
||||||
<div class="mc-filter-row" id="caseFilterChips"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div id="view-overview" class="mc-view active">
|
<div id="view-overview" class="mc-view active">
|
||||||
<div class="mc-view-grid">
|
<div class="mc-view-grid">
|
||||||
@ -658,6 +903,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="view-day" class="mc-view">
|
||||||
|
<div class="mc-card">
|
||||||
|
<h4 class="mb-2">Dagen</h4>
|
||||||
|
<div class="mc-subtle mb-3">Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.</div>
|
||||||
|
|
||||||
|
<div class="mc-day-tabs" id="dayTabs">
|
||||||
|
<button type="button" class="mc-day-tab active" data-day-tab="new_cases">Nye sager</button>
|
||||||
|
<button type="button" class="mc-day-tab" data-day-tab="today">Idag</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dayPane-new_cases" class="mc-day-pane active">
|
||||||
|
<div id="dayUnassignedList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dayPane-today" class="mc-day-pane">
|
||||||
|
<div id="dayAgentsList" class="mc-day-agents"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="view-calls" class="mc-view">
|
<div id="view-calls" class="mc-view">
|
||||||
<div class="mc-view-grid">
|
<div class="mc-view-grid">
|
||||||
<div class="mc-card">
|
<div class="mc-card">
|
||||||
@ -695,10 +960,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mc-card">
|
|
||||||
<h5 class="mb-2">Live aktivitetsfeed</h5>
|
|
||||||
<div id="liveFeed" class="mc-feed"></div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -730,6 +991,7 @@
|
|||||||
idleTimeoutMs: 10000,
|
idleTimeoutMs: 10000,
|
||||||
currentView: 'overview',
|
currentView: 'overview',
|
||||||
caseFilter: 'all',
|
caseFilter: 'all',
|
||||||
|
dayTab: 'new_cases',
|
||||||
preSpotlightView: null,
|
preSpotlightView: null,
|
||||||
cameraSpotlightTimer: null,
|
cameraSpotlightTimer: null,
|
||||||
spotlightTargetId: null,
|
spotlightTargetId: null,
|
||||||
@ -751,6 +1013,10 @@
|
|||||||
activeAlerts: [],
|
activeAlerts: [],
|
||||||
liveFeed: [],
|
liveFeed: [],
|
||||||
importantCases: [],
|
importantCases: [],
|
||||||
|
dayUnassignedCases: [],
|
||||||
|
dayAgentWorkloads: [],
|
||||||
|
assignmentUsers: [],
|
||||||
|
assignmentGroups: [],
|
||||||
recentEmails: [],
|
recentEmails: [],
|
||||||
environmentReadings: [],
|
environmentReadings: [],
|
||||||
cameraMotion: null,
|
cameraMotion: null,
|
||||||
@ -789,6 +1055,20 @@
|
|||||||
return `/sag/${id}/v3`;
|
return `/sag/${id}/v3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toOptionalInt(value) {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(value, maxLength = 180) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.length <= maxLength) return raw;
|
||||||
|
return `${raw.slice(0, maxLength - 1)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
function getEmailHref(emailId) {
|
function getEmailHref(emailId) {
|
||||||
const id = Number(emailId || 0);
|
const id = Number(emailId || 0);
|
||||||
if (!Number.isFinite(id) || id <= 0) return '/emails';
|
if (!Number.isFinite(id) || id <= 0) return '/emails';
|
||||||
@ -1183,6 +1463,190 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDayTabs() {
|
||||||
|
const host = document.getElementById('dayTabs');
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
host.querySelectorAll('.mc-day-tab').forEach((btn) => {
|
||||||
|
const key = btn.dataset.dayTab || 'new_cases';
|
||||||
|
btn.classList.toggle('active', key === state.dayTab);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.mc-day-pane').forEach((pane) => {
|
||||||
|
pane.classList.remove('active');
|
||||||
|
});
|
||||||
|
const activePane = document.getElementById(`dayPane-${state.dayTab}`);
|
||||||
|
if (activePane) {
|
||||||
|
activePane.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDayUnassignedCases() {
|
||||||
|
const list = document.getElementById('dayUnassignedList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const rows = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
list.innerHTML = '<div class="mc-feed-meta">Ingen ikke-tildelte sager lige nu.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOptions = (state.assignmentUsers || []).map((user) => (
|
||||||
|
`<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>`
|
||||||
|
)).join('');
|
||||||
|
const groupOptions = (state.assignmentGroups || []).map((group) => (
|
||||||
|
`<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>`
|
||||||
|
)).join('');
|
||||||
|
|
||||||
|
list.innerHTML = rows.slice(0, 120).map((item) => {
|
||||||
|
const caseId = Number(item.id || 0);
|
||||||
|
const title = item.titel || 'Uden titel';
|
||||||
|
const desc = truncateText(item.beskrivelse || '', 220);
|
||||||
|
const currentUserId = Number(item.ansvarlig_bruger_id || 0);
|
||||||
|
const currentGroupId = Number(item.assigned_group_id || 0);
|
||||||
|
const hasUser = Number.isFinite(currentUserId) && currentUserId > 0;
|
||||||
|
const hasGroup = Number.isFinite(currentGroupId) && currentGroupId > 0;
|
||||||
|
const missingParts = [];
|
||||||
|
if (!hasUser) missingParts.push('mangler tekniker');
|
||||||
|
if (!hasGroup) missingParts.push('mangler gruppe');
|
||||||
|
|
||||||
|
const userSelectOptions = [
|
||||||
|
'<option value="">Tildel tekniker...</option>',
|
||||||
|
userOptions,
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const groupSelectOptions = [
|
||||||
|
'<option value="">Tildel gruppe...</option>',
|
||||||
|
groupOptions,
|
||||||
|
].join('');
|
||||||
|
return `
|
||||||
|
<div class="mc-day-case-card" id="dayCase-${caseId}">
|
||||||
|
<div class="mc-day-case-title">
|
||||||
|
<a class="mc-case-link" href="${getCaseHref(caseId)}">#${caseId} ${escapeHtml(title)}</a>
|
||||||
|
</div>
|
||||||
|
<div class="mc-day-case-sub">
|
||||||
|
${escapeHtml(item.customer_name || 'Ukendt kunde')} • Status: ${escapeHtml(item.status || '-')} • Start: ${escapeHtml(formatShortDate(item.start_date))} • Deadline: ${escapeHtml(formatShortDate(item.deadline))}
|
||||||
|
${missingParts.length ? ` • ${escapeHtml(missingParts.join(' + '))}` : ''}
|
||||||
|
</div>
|
||||||
|
${desc ? `<div class="mc-day-case-desc">${escapeHtml(desc)}</div>` : ''}
|
||||||
|
<div class="mc-day-assign-row">
|
||||||
|
<select class="mc-day-select" id="assignUser-${caseId}">
|
||||||
|
${userSelectOptions}
|
||||||
|
</select>
|
||||||
|
<select class="mc-day-select" id="assignGroup-${caseId}">
|
||||||
|
${groupSelectOptions}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
rows.slice(0, 120).forEach((item) => {
|
||||||
|
const caseId = Number(item.id || 0);
|
||||||
|
const userSelect = document.getElementById(`assignUser-${caseId}`);
|
||||||
|
const groupSelect = document.getElementById(`assignGroup-${caseId}`);
|
||||||
|
const currentUserId = Number(item.ansvarlig_bruger_id || 0);
|
||||||
|
const currentGroupId = Number(item.assigned_group_id || 0);
|
||||||
|
|
||||||
|
if (userSelect && Number.isFinite(currentUserId) && currentUserId > 0) {
|
||||||
|
userSelect.value = String(currentUserId);
|
||||||
|
}
|
||||||
|
if (groupSelect && Number.isFinite(currentGroupId) && currentGroupId > 0) {
|
||||||
|
groupSelect.value = String(currentGroupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDayAgentWorkloads() {
|
||||||
|
const host = document.getElementById('dayAgentsList');
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const rows = Array.isArray(state.dayAgentWorkloads) ? state.dayAgentWorkloads : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
host.innerHTML = '<div class="mc-feed-meta">Ingen aktive agent-opgaver med start/deadline i dag eller tidligere.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
host.innerHTML = rows.map((agent) => {
|
||||||
|
const cases = Array.isArray(agent.case_list) ? agent.case_list : [];
|
||||||
|
return `
|
||||||
|
<div class="mc-agent-card">
|
||||||
|
<div class="mc-agent-head">
|
||||||
|
<div class="mc-agent-name">${escapeHtml(agent.assignee_name || 'Ufordelt')}</div>
|
||||||
|
<span class="mc-badge mc-day-mini">${Number(agent.total_cases || 0)} sager</span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-agent-metrics">
|
||||||
|
<span class="mc-badge mc-day-mini warn">I dag: ${Number(agent.due_today_cases || 0)}</span>
|
||||||
|
<span class="mc-badge mc-day-mini alert">Overskredet: ${Number(agent.overdue_cases || 0)}</span>
|
||||||
|
<span class="mc-badge mc-day-mini">Startet: ${Number(agent.started_cases || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mc-day-agent-cases">
|
||||||
|
${cases.length ? cases.map((item) => `
|
||||||
|
<div class="mc-day-agent-case">
|
||||||
|
<a class="mc-case-link" href="${getCaseHref(item.id)}">#${Number(item.id || 0)} ${escapeHtml(item.titel || 'Uden titel')}</a>
|
||||||
|
<div class="mc-case-sub">${escapeHtml(item.customer_name || 'Ukendt kunde')} • Start: ${escapeHtml(formatShortDate(item.start_date))} • Deadline: ${escapeHtml(formatShortDate(item.deadline))}</div>
|
||||||
|
</div>
|
||||||
|
`).join('') : '<div class="mc-feed-meta">Ingen sager i listen</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickAssignCase(caseId) {
|
||||||
|
const id = Number(caseId || 0);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return;
|
||||||
|
|
||||||
|
const userSelect = document.getElementById(`assignUser-${id}`);
|
||||||
|
const groupSelect = document.getElementById(`assignGroup-${id}`);
|
||||||
|
const card = document.getElementById(`dayCase-${id}`);
|
||||||
|
const button = card?.querySelector('.mc-day-btn');
|
||||||
|
|
||||||
|
const ansvarlig_bruger_id = toOptionalInt(userSelect?.value);
|
||||||
|
const assigned_group_id = toOptionalInt(groupSelect?.value);
|
||||||
|
|
||||||
|
if (ansvarlig_bruger_id === null && assigned_group_id === null) {
|
||||||
|
alert('Vælg tekniker eller gruppe først.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ansvarlig_bruger_id,
|
||||||
|
assigned_group_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Gemmer...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/sag/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err?.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadInitialState();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Kunne ikke tildele sag: ${error?.message || 'ukendt fejl'}`);
|
||||||
|
} finally {
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Tildel';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.quickAssignCase = quickAssignCase;
|
||||||
|
|
||||||
function renderEnvironmentReadings() {
|
function renderEnvironmentReadings() {
|
||||||
const container = document.getElementById('environmentReadings');
|
const container = document.getElementById('environmentReadings');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@ -1286,6 +1750,9 @@
|
|||||||
renderActiveCalls();
|
renderActiveCalls();
|
||||||
renderDeadlines();
|
renderDeadlines();
|
||||||
renderImportantCases();
|
renderImportantCases();
|
||||||
|
renderDayTabs();
|
||||||
|
renderDayUnassignedCases();
|
||||||
|
renderDayAgentWorkloads();
|
||||||
renderRecentEmails();
|
renderRecentEmails();
|
||||||
renderEnvironmentReadings();
|
renderEnvironmentReadings();
|
||||||
renderFeed();
|
renderFeed();
|
||||||
@ -1304,6 +1771,10 @@
|
|||||||
state.activeAlerts = Array.isArray(payload.active_alerts) ? payload.active_alerts : state.activeAlerts;
|
state.activeAlerts = Array.isArray(payload.active_alerts) ? payload.active_alerts : state.activeAlerts;
|
||||||
state.liveFeed = Array.isArray(payload.live_feed) ? payload.live_feed : state.liveFeed;
|
state.liveFeed = Array.isArray(payload.live_feed) ? payload.live_feed : state.liveFeed;
|
||||||
state.importantCases = Array.isArray(payload.important_cases) ? payload.important_cases : state.importantCases;
|
state.importantCases = Array.isArray(payload.important_cases) ? payload.important_cases : state.importantCases;
|
||||||
|
state.dayUnassignedCases = Array.isArray(payload.day_unassigned_cases) ? payload.day_unassigned_cases : state.dayUnassignedCases;
|
||||||
|
state.dayAgentWorkloads = Array.isArray(payload.day_agent_workloads) ? payload.day_agent_workloads : state.dayAgentWorkloads;
|
||||||
|
state.assignmentUsers = Array.isArray(payload.assignment_users) ? payload.assignment_users : state.assignmentUsers;
|
||||||
|
state.assignmentGroups = Array.isArray(payload.assignment_groups) ? payload.assignment_groups : state.assignmentGroups;
|
||||||
state.recentEmails = Array.isArray(payload.recent_emails) ? payload.recent_emails : state.recentEmails;
|
state.recentEmails = Array.isArray(payload.recent_emails) ? payload.recent_emails : state.recentEmails;
|
||||||
state.environmentReadings = Array.isArray(payload.environment_readings) ? payload.environment_readings : state.environmentReadings;
|
state.environmentReadings = Array.isArray(payload.environment_readings) ? payload.environment_readings : state.environmentReadings;
|
||||||
|
|
||||||
@ -1449,6 +1920,14 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('dayTabs')?.querySelectorAll('.mc-day-tab').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.dayTab = btn.dataset.dayTab || 'new_cases';
|
||||||
|
renderDayTabs();
|
||||||
|
resetIdleTimer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
|
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
|
||||||
document.addEventListener(name, resetIdleTimer, { passive: true });
|
document.addEventListener(name, resetIdleTimer, { passive: true });
|
||||||
});
|
});
|
||||||
|
|||||||
0
app/modules/fedex/frontend/__init__.py
Normal file
0
app/modules/fedex/frontend/__init__.py
Normal file
351
app/modules/fedex/frontend/fedex_overview.html
Normal file
351
app/modules/fedex/frontend/fedex_overview.html
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
{% extends "shared/frontend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}FedEx Overblik - BMC Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.fedex-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-hero {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
background: linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(26, 117, 159, 0.08));
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpis {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpi {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpi .label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-kpi .value {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-filter-card {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-table-wrap {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.14);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-table tbody td {
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-status {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; }
|
||||||
|
.fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; }
|
||||||
|
.fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; }
|
||||||
|
.fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: #146c43; }
|
||||||
|
.fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; }
|
||||||
|
|
||||||
|
.fedex-row-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fedex-row-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.fedex-kpis {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fedex-kpis {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="fedex-shell">
|
||||||
|
<div class="fedex-hero">
|
||||||
|
<div>
|
||||||
|
<h2 class="fw-bold mb-1">FedEx Overblik</h2>
|
||||||
|
<div class="text-muted">Samlet visning af alle FedEx bestillinger og deres status.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-primary" id="refreshFedexBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fedex-kpis">
|
||||||
|
<div class="fedex-kpi"><div class="label">Total</div><div class="value" id="kpiTotal">0</div></div>
|
||||||
|
<div class="fedex-kpi"><div class="label">Aktive</div><div class="value" id="kpiActive">0</div></div>
|
||||||
|
<div class="fedex-kpi"><div class="label">Leveret</div><div class="value" id="kpiDelivered">0</div></div>
|
||||||
|
<div class="fedex-kpi"><div class="label">Fejl/Annulleret</div><div class="value" id="kpiFailed">0</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card fedex-filter-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<label class="form-label small text-muted" for="fedexSearchInput">Søg</label>
|
||||||
|
<input id="fedexSearchInput" class="form-control" placeholder="Booking ref, tracking, modtager, by, case id...">
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<label class="form-label small text-muted" for="fedexStatusFilter">Status</label>
|
||||||
|
<select id="fedexStatusFilter" class="form-select">
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="submitted">Submitted</option>
|
||||||
|
<option value="booked">Booked</option>
|
||||||
|
<option value="in_transit">In transit</option>
|
||||||
|
<option value="delivered">Delivered</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-2 col-md-6">
|
||||||
|
<label class="form-label small text-muted" for="fedexSortSelect">Sortering</label>
|
||||||
|
<select id="fedexSortSelect" class="form-select">
|
||||||
|
<option value="newest">Nyeste først</option>
|
||||||
|
<option value="oldest">Ældste først</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-2 d-flex align-items-end">
|
||||||
|
<button class="btn btn-light border w-100" id="fedexClearBtn">Ryd filtre</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fedex-table-wrap">
|
||||||
|
<table class="table table-hover fedex-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bestilling</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Tracking</th>
|
||||||
|
<th>Case</th>
|
||||||
|
<th>Afhentning</th>
|
||||||
|
<th>Pris</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="fedexTableBody">
|
||||||
|
<tr><td colspan="7" class="text-center py-4 text-muted">Henter FedEx bestillinger...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
bookings: [],
|
||||||
|
filtered: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = value ?? '';
|
||||||
|
return span.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const dt = new Date(value);
|
||||||
|
if (Number.isNaN(dt.getTime())) return '-';
|
||||||
|
return dt.toLocaleString('da-DK');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(amount, currency) {
|
||||||
|
if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '-';
|
||||||
|
return `${Number(amount).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency || 'DKK'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
const s = String(status || 'draft').toLowerCase();
|
||||||
|
return `<span class="fedex-status ${escapeHtml(s)}">${escapeHtml(s.replaceAll('_', ' '))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const q = (document.getElementById('fedexSearchInput')?.value || '').trim().toLowerCase();
|
||||||
|
const status = (document.getElementById('fedexStatusFilter')?.value || 'all').toLowerCase();
|
||||||
|
const sortBy = (document.getElementById('fedexSortSelect')?.value || 'newest').toLowerCase();
|
||||||
|
|
||||||
|
const rows = state.bookings.filter((item) => {
|
||||||
|
const itemStatus = String(item.shipment_status || '').toLowerCase();
|
||||||
|
if (status !== 'all' && itemStatus !== status) return false;
|
||||||
|
|
||||||
|
if (!q) return true;
|
||||||
|
const haystack = [
|
||||||
|
item.booking_ref,
|
||||||
|
item.tracking_number,
|
||||||
|
item.recipient_name,
|
||||||
|
item.city,
|
||||||
|
item.country_code,
|
||||||
|
item.service_type,
|
||||||
|
item.case_id,
|
||||||
|
].map((v) => String(v || '').toLowerCase()).join(' ');
|
||||||
|
return haystack.includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (sortBy === 'status') {
|
||||||
|
return String(a.shipment_status || '').localeCompare(String(b.shipment_status || ''), 'da');
|
||||||
|
}
|
||||||
|
const ta = new Date(a.created_at || 0).getTime() || 0;
|
||||||
|
const tb = new Date(b.created_at || 0).getTime() || 0;
|
||||||
|
return sortBy === 'oldest' ? ta - tb : tb - ta;
|
||||||
|
});
|
||||||
|
|
||||||
|
state.filtered = rows;
|
||||||
|
renderTable();
|
||||||
|
renderKpis();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKpis() {
|
||||||
|
const total = state.filtered.length;
|
||||||
|
const delivered = state.filtered.filter((item) => item.shipment_status === 'delivered').length;
|
||||||
|
const failed = state.filtered.filter((item) => ['failed', 'cancelled'].includes(String(item.shipment_status || '').toLowerCase())).length;
|
||||||
|
const active = state.filtered.filter((item) => ['draft', 'submitted', 'booked', 'in_transit'].includes(String(item.shipment_status || '').toLowerCase())).length;
|
||||||
|
|
||||||
|
document.getElementById('kpiTotal').textContent = String(total);
|
||||||
|
document.getElementById('kpiActive').textContent = String(active);
|
||||||
|
document.getElementById('kpiDelivered').textContent = String(delivered);
|
||||||
|
document.getElementById('kpiFailed').textContent = String(failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = document.getElementById('fedexTableBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
if (!state.filtered.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Ingen FedEx bestillinger matcher filteret.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = state.filtered.map((item) => {
|
||||||
|
const trackingNumber = String(item.tracking_number || '').trim();
|
||||||
|
const trackingUrl = String(item.tracking_url || (trackingNumber ? `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}` : '')).trim();
|
||||||
|
const labelUrl = String(item.label_url || '').trim();
|
||||||
|
const openCaseUrl = Number(item.case_id) > 0 ? `/sag/${Number(item.case_id)}/v3` : '/sag';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fedex-row-title">${escapeHtml(item.booking_ref || '-')}</div>
|
||||||
|
<div class="fedex-row-meta">${escapeHtml(item.recipient_name || '-')} • ${escapeHtml(item.city || '-')} (${escapeHtml(item.country_code || '-')})</div>
|
||||||
|
</td>
|
||||||
|
<td>${statusBadge(item.shipment_status)}</td>
|
||||||
|
<td>
|
||||||
|
${trackingNumber ? `<span class="small fw-semibold">${escapeHtml(trackingNumber)}</span>` : '<span class="text-muted">-</span>'}
|
||||||
|
</td>
|
||||||
|
<td><a href="${openCaseUrl}" class="text-decoration-none">#${Number(item.case_id || 0)}</a></td>
|
||||||
|
<td>${escapeHtml(formatDate(item.pickup_window_start))}</td>
|
||||||
|
<td>${escapeHtml(formatMoney(item.total_amount, item.currency))}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
${trackingUrl ? `<a href="${trackingUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
|
||||||
|
${labelUrl ? `<a href="${labelUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary ms-1"><i class="bi bi-file-earmark-text"></i></a>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBookings() {
|
||||||
|
const tbody = document.getElementById('fedexTableBody');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter FedEx bestillinger...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/fedex/bookings', { credentials: 'include' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
state.bookings = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
|
applyFilters();
|
||||||
|
} catch (error) {
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4 text-danger">Kunne ikke hente FedEx bestillinger: ${escapeHtml(error.message || 'ukendt fejl')}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
document.getElementById('fedexSearchInput')?.addEventListener('input', applyFilters);
|
||||||
|
document.getElementById('fedexStatusFilter')?.addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('fedexSortSelect')?.addEventListener('change', applyFilters);
|
||||||
|
|
||||||
|
document.getElementById('fedexClearBtn')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('fedexSearchInput').value = '';
|
||||||
|
document.getElementById('fedexStatusFilter').value = 'all';
|
||||||
|
document.getElementById('fedexSortSelect').value = 'newest';
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('refreshFedexBtn')?.addEventListener('click', loadBookings);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
bindEvents();
|
||||||
|
loadBookings();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
14
app/modules/fedex/frontend/views.py
Normal file
14
app/modules/fedex/frontend/views.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support/fedex", response_class=HTMLResponse)
|
||||||
|
async def fedex_overview_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/fedex/frontend/fedex_overview.html",
|
||||||
|
{"request": request},
|
||||||
|
)
|
||||||
@ -24,6 +24,10 @@
|
|||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.links-summary .summary-card {
|
||||||
|
box-shadow: 0 4px 14px rgba(2, 32, 71, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.summary-card {
|
.summary-card {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.62rem 0.7rem;
|
padding: 0.62rem 0.7rem;
|
||||||
@ -66,6 +70,25 @@
|
|||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.links-results-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: 0.45rem 0 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-chip {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.16rem 0.5rem;
|
||||||
|
color: var(--accent, #0f4c75);
|
||||||
|
background: rgba(15, 76, 117, 0.06);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.flame-category {
|
.flame-category {
|
||||||
margin-bottom: 1.2rem;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
@ -296,6 +319,87 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle .btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
padding: 0.38rem 0.45rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle .btn.active {
|
||||||
|
background: rgba(15, 76, 117, 0.12);
|
||||||
|
color: var(--accent, #0f4c75);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list-card {
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: 0 8px 22px rgba(2, 32, 71, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list-table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list-table thead th {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(15, 76, 117, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(15, 76, 117, 0.12);
|
||||||
|
padding-top: 0.62rem;
|
||||||
|
padding-bottom: 0.62rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list-table tbody td {
|
||||||
|
vertical-align: middle;
|
||||||
|
border-color: rgba(15, 76, 117, 0.08);
|
||||||
|
padding-top: 0.52rem;
|
||||||
|
padding-bottom: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list-table tbody tr:hover {
|
||||||
|
background: rgba(15, 76, 117, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-name:hover {
|
||||||
|
color: var(--accent, #0f4c75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.28rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-actions .btn {
|
||||||
|
padding: 0.2rem 0.42rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
@ -310,6 +414,11 @@
|
|||||||
.target-text {
|
.target-text {
|
||||||
max-width: 170px;
|
max-width: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.links-results-meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -353,7 +462,7 @@
|
|||||||
<div class="card border-0 shadow-sm mb-2 links-filter-card">
|
<div class="card border-0 shadow-sm mb-2 links-filter-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
<div class="col-md-6">
|
<div class="col-lg-5">
|
||||||
<label for="searchInput" class="form-label small text-muted">Søg</label>
|
<label for="searchInput" class="form-label small text-muted">Søg</label>
|
||||||
<div class="input-group search-shell">
|
<div class="input-group search-shell">
|
||||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
@ -361,7 +470,7 @@
|
|||||||
<button id="clearSearchBtn" class="btn btn-sm" type="button" title="Ryd søgning"><i class="bi bi-x-lg"></i></button>
|
<button id="clearSearchBtn" class="btn btn-sm" type="button" title="Ryd søgning"><i class="bi bi-x-lg"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-lg-2 col-md-4">
|
||||||
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
<label for="statusFilter" class="form-label small text-muted">Status</label>
|
||||||
<select id="statusFilter" class="form-select">
|
<select id="statusFilter" class="form-select">
|
||||||
<option value="all">Alle</option>
|
<option value="all">Alle</option>
|
||||||
@ -370,10 +479,28 @@
|
|||||||
<option value="unknown">Unknown</option>
|
<option value="unknown">Unknown</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-4">
|
||||||
|
<label for="categoryFilter" class="form-label small text-muted">Kategori</label>
|
||||||
|
<select id="categoryFilter" class="form-select">
|
||||||
|
<option value="all">Alle kategorier</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-2 col-md-4">
|
||||||
|
<label class="form-label small text-muted">Visning</label>
|
||||||
|
<div class="view-toggle" role="group" aria-label="Skift visning">
|
||||||
|
<button class="btn active" id="cardsViewBtn" type="button"><i class="bi bi-grid-3x2-gap me-1"></i>Kort</button>
|
||||||
|
<button class="btn" id="listViewBtn" type="button"><i class="bi bi-list-ul me-1"></i>Liste</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="links-results-meta">
|
||||||
|
<div id="resultsMeta">Viser 0 links</div>
|
||||||
|
<div class="results-chip" id="resultsScope">Scope: Alle</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="links-container">
|
<div class="links-container">
|
||||||
<div id="linksColumns">
|
<div id="linksColumns">
|
||||||
<div class="text-muted small">Henter links...</div>
|
<div class="text-muted small">Henter links...</div>
|
||||||
@ -531,6 +658,7 @@
|
|||||||
links: [],
|
links: [],
|
||||||
statuses: new Map(),
|
statuses: new Map(),
|
||||||
categories: [],
|
categories: [],
|
||||||
|
viewMode: localStorage.getItem('linksViewMode') || 'cards',
|
||||||
};
|
};
|
||||||
|
|
||||||
let linkModal;
|
let linkModal;
|
||||||
@ -634,6 +762,7 @@
|
|||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
const q = (document.getElementById('searchInput').value || '').toLowerCase().trim();
|
const q = (document.getElementById('searchInput').value || '').toLowerCase().trim();
|
||||||
const statusFilter = document.getElementById('statusFilter').value;
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const categoryFilter = document.getElementById('categoryFilter').value;
|
||||||
|
|
||||||
const rows = state.links.filter((link) => {
|
const rows = state.links.filter((link) => {
|
||||||
const statusRow = state.statuses.get(link.id);
|
const statusRow = state.statuses.get(link.id);
|
||||||
@ -643,15 +772,35 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (categoryFilter !== 'all') {
|
||||||
|
const ids = Array.isArray(link.category_ids) ? link.category_ids.map((v) => String(v)) : [];
|
||||||
|
if (!ids.includes(String(categoryFilter))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
const categoryNames = resolveCategoryNamesForLink(link).join(' ');
|
const categoryNames = resolveCategoryNamesForLink(link).join(' ');
|
||||||
const hay = `${link.name || ''} ${link.url || ''} ${link.host || ''} ${link.environment || ''} ${link.type || ''} ${categoryNames}`.toLowerCase();
|
const hay = `${link.name || ''} ${link.url || ''} ${link.host || ''} ${link.environment || ''} ${link.type || ''} ${categoryNames}`.toLowerCase();
|
||||||
return hay.includes(q);
|
return hay.includes(q);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
renderResultsMeta(rows.length);
|
||||||
renderTable(rows);
|
renderTable(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderResultsMeta(filteredCount) {
|
||||||
|
const total = state.links.length;
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const categorySelect = document.getElementById('categoryFilter');
|
||||||
|
const categoryLabel = categorySelect?.selectedOptions?.[0]?.textContent || 'Alle kategorier';
|
||||||
|
|
||||||
|
document.getElementById('resultsMeta').textContent = `Viser ${filteredCount} af ${total} links`;
|
||||||
|
|
||||||
|
const statusText = statusFilter === 'all' ? 'Alle statusser' : statusFilter.toUpperCase();
|
||||||
|
document.getElementById('resultsScope').textContent = `Status: ${statusText} · ${categoryLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
function categoryNameForLink(link) {
|
function categoryNameForLink(link) {
|
||||||
const ids = Array.isArray(link.category_ids) ? link.category_ids : [];
|
const ids = Array.isArray(link.category_ids) ? link.category_ids : [];
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
@ -752,10 +901,115 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderList(rows) {
|
||||||
|
const container = document.getElementById('linksColumns');
|
||||||
|
if (!rows.length) {
|
||||||
|
container.innerHTML = '<div class="text-muted text-center py-4">Ingen links fundet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = [...rows].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'da-DK'));
|
||||||
|
|
||||||
|
const rowsHtml = ordered.map((link) => {
|
||||||
|
const statusRow = state.statuses.get(link.id);
|
||||||
|
const status = statusRow ? statusRow.status : 'unknown';
|
||||||
|
const names = resolveCategoryNamesForLink(link);
|
||||||
|
const category = names[0] || 'Ukategoriseret';
|
||||||
|
const target = resolveTarget(link);
|
||||||
|
const openHref = buildOpenHref(link);
|
||||||
|
const canOpen = Boolean(openHref);
|
||||||
|
const hasVault = Boolean(link.vault_item_id) || (Array.isArray(link.vault_item_ids) && link.vault_item_ids.length > 0);
|
||||||
|
const env = String(link.environment || 'prod').toUpperCase();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="flame-status-dot ${status}" title="Status: ${status}"></span>
|
||||||
|
${canOpen
|
||||||
|
? `<a class="list-name" href="${escapeHtml(openHref)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.name || '-')}</a>`
|
||||||
|
: `<span class="list-name">${escapeHtml(link.name || '-')}</span>`}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="mono target-text" title="${escapeHtml(target)}">${escapeHtml(target)}</span></td>
|
||||||
|
<td><span class="link-env">${escapeHtml(env)}</span></td>
|
||||||
|
<td><span class="small text-muted">${escapeHtml(category)}</span></td>
|
||||||
|
<td>${statusPill(status)}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="list-actions">
|
||||||
|
${hasVault ? `<button class="btn btn-sm btn-outline-primary" onclick="resolveVault(${link.id}); event.preventDefault();" title="Vault"><i class="bi bi-shield-lock"></i></button>` : ''}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="openEditModal(${link.id}); event.preventDefault();" title="Rediger"><i class="bi bi-pencil"></i></button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteLink(${link.id}, '${escapeHtml(link.name || '')}'); event.preventDefault();" title="Slet"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="links-list-card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table links-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Miljø</th>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rowsHtml}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(status) {
|
||||||
|
if (status === 'ok') return '<span class="status-pill status-ok">OK</span>';
|
||||||
|
if (status === 'down') return '<span class="status-pill status-down">Down</span>';
|
||||||
|
return '<span class="status-pill status-unknown">Unknown</span>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderTable(rows) {
|
function renderTable(rows) {
|
||||||
|
if (state.viewMode === 'list') {
|
||||||
|
renderList(rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
renderColumns(rows);
|
renderColumns(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setViewMode(mode) {
|
||||||
|
state.viewMode = mode === 'list' ? 'list' : 'cards';
|
||||||
|
localStorage.setItem('linksViewMode', state.viewMode);
|
||||||
|
|
||||||
|
document.getElementById('cardsViewBtn').classList.toggle('active', state.viewMode === 'cards');
|
||||||
|
document.getElementById('listViewBtn').classList.toggle('active', state.viewMode === 'list');
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateCategoryFilter() {
|
||||||
|
const select = document.getElementById('categoryFilter');
|
||||||
|
const previousValue = select.value || 'all';
|
||||||
|
|
||||||
|
const options = state.categories
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'da-DK'))
|
||||||
|
.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
select.innerHTML = `<option value="all">Alle kategorier</option>${options}`;
|
||||||
|
|
||||||
|
if (previousValue !== 'all' && Array.from(select.options).some((opt) => opt.value === previousValue)) {
|
||||||
|
select.value = previousValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderSummary() {
|
function renderSummary() {
|
||||||
const counts = { ok: 0, down: 0, unknown: 0 };
|
const counts = { ok: 0, down: 0, unknown: 0 };
|
||||||
state.links.forEach((link) => {
|
state.links.forEach((link) => {
|
||||||
@ -799,6 +1053,7 @@
|
|||||||
}
|
}
|
||||||
const select = document.getElementById('fCategoryIds');
|
const select = document.getElementById('fCategoryIds');
|
||||||
select.innerHTML = state.categories.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join('');
|
select.innerHTML = state.categories.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join('');
|
||||||
|
populateCategoryFilter();
|
||||||
|
|
||||||
if (state.links.length) {
|
if (state.links.length) {
|
||||||
applyFilters();
|
applyFilters();
|
||||||
@ -990,11 +1245,14 @@
|
|||||||
|
|
||||||
document.getElementById('searchInput').addEventListener('input', applyFilters);
|
document.getElementById('searchInput').addEventListener('input', applyFilters);
|
||||||
document.getElementById('statusFilter').addEventListener('change', applyFilters);
|
document.getElementById('statusFilter').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('categoryFilter').addEventListener('change', applyFilters);
|
||||||
document.getElementById('clearSearchBtn').addEventListener('click', () => {
|
document.getElementById('clearSearchBtn').addEventListener('click', () => {
|
||||||
document.getElementById('searchInput').value = '';
|
document.getElementById('searchInput').value = '';
|
||||||
applyFilters();
|
applyFilters();
|
||||||
document.getElementById('searchInput').focus();
|
document.getElementById('searchInput').focus();
|
||||||
});
|
});
|
||||||
|
document.getElementById('cardsViewBtn').addEventListener('click', () => setViewMode('cards'));
|
||||||
|
document.getElementById('listViewBtn').addEventListener('click', () => setViewMode('list'));
|
||||||
document.getElementById('refreshBtn').addEventListener('click', loadData);
|
document.getElementById('refreshBtn').addEventListener('click', loadData);
|
||||||
document.getElementById('runHealthBtn').addEventListener('click', runHealthCheck);
|
document.getElementById('runHealthBtn').addEventListener('click', runHealthCheck);
|
||||||
document.getElementById('createBtn').addEventListener('click', () => {
|
document.getElementById('createBtn').addEventListener('click', () => {
|
||||||
@ -1008,8 +1266,11 @@
|
|||||||
|
|
||||||
if (customerIdFilter) {
|
if (customerIdFilter) {
|
||||||
document.getElementById('scopeHint').textContent = `Filter: Kunde #${customerIdFilter}`;
|
document.getElementById('scopeHint').textContent = `Filter: Kunde #${customerIdFilter}`;
|
||||||
|
document.getElementById('resultsScope').textContent = `Scope: Kunde #${customerIdFilter}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setViewMode(state.viewMode);
|
||||||
|
|
||||||
Promise.all([loadCategories(), loadData()]);
|
Promise.all([loadCategories(), loadData()]);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import base64
|
import base64
|
||||||
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -96,16 +97,31 @@ def _get_client_ip(request: Request) -> str:
|
|||||||
if cf_ip:
|
if cf_ip:
|
||||||
return cf_ip.strip()
|
return cf_ip.strip()
|
||||||
|
|
||||||
x_real_ip = request.headers.get("x-real-ip")
|
true_client_ip = request.headers.get("true-client-ip")
|
||||||
if x_real_ip:
|
if true_client_ip:
|
||||||
return x_real_ip.strip()
|
return true_client_ip.strip()
|
||||||
|
|
||||||
xff = request.headers.get("x-forwarded-for")
|
xff = request.headers.get("x-forwarded-for")
|
||||||
if xff:
|
if xff:
|
||||||
return xff.split(",")[0].strip()
|
return xff.split(",")[0].strip()
|
||||||
|
|
||||||
|
x_real_ip = request.headers.get("x-real-ip")
|
||||||
|
if x_real_ip:
|
||||||
|
return x_real_ip.strip()
|
||||||
|
|
||||||
return request.client.host if request.client else ""
|
return request.client.host if request.client else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_bmc_ip(client_ip: str) -> bool:
|
||||||
|
if not client_ip:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ip_obj = ipaddress.ip_address(client_ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return ip_obj in ipaddress.ip_network("172.16.31.0/24")
|
||||||
|
|
||||||
|
|
||||||
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
|
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
|
||||||
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
|
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
|
||||||
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
|
||||||
@ -520,7 +536,12 @@ def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/telefoni/click-to-call")
|
@router.post("/telefoni/click-to-call")
|
||||||
async def click_to_call(payload: TelefoniClickToCallRequest):
|
async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
|
||||||
|
client_ip = _get_client_ip(request)
|
||||||
|
if not _is_internal_bmc_ip(client_ip):
|
||||||
|
logger.warning("⚠️ Click-to-call blocked for non-internal IP: %s", client_ip or "unknown")
|
||||||
|
raise HTTPException(status_code=403, detail="Click-to-call is only available on internal network")
|
||||||
|
|
||||||
enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true"
|
enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true"
|
||||||
if not enabled:
|
if not enabled:
|
||||||
raise HTTPException(status_code=400, detail="Click-to-call is disabled")
|
raise HTTPException(status_code=400, detail="Click-to-call is disabled")
|
||||||
|
|||||||
@ -67,6 +67,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="linkContactModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Knyt eller opret kontakt</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="linkContactContext" class="small text-muted mb-3">Opkald: -</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="linkContactSearch" class="form-label">Søg eksisterende kontakt</label>
|
||||||
|
<input id="linkContactSearch" type="text" class="form-control" placeholder="Søg navn, email, telefon..." autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div id="linkContactSelected" class="alert alert-secondary py-2 mb-3">Ingen kontakt valgt</div>
|
||||||
|
<div id="linkContactResults" class="list-group mb-3"></div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6 class="mb-3">Eller opret ny kontakt</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="newContactFirstName" class="form-label">Fornavn</label>
|
||||||
|
<input id="newContactFirstName" type="text" class="form-control" placeholder="Fornavn" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="newContactLastName" class="form-label">Efternavn</label>
|
||||||
|
<input id="newContactLastName" type="text" class="form-control" placeholder="Efternavn" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="newContactPhone" class="form-label">Telefon</label>
|
||||||
|
<input id="newContactPhone" type="text" class="form-control" placeholder="Telefonnummer" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="newContactEmail" class="form-label">Email</label>
|
||||||
|
<input id="newContactEmail" type="email" class="form-control" placeholder="mail@firma.dk" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="newContactTitle" class="form-label">Titel</label>
|
||||||
|
<input id="newContactTitle" type="text" class="form-control" placeholder="Fx IT-ansvarlig" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6 class="mb-3">Eller brug firmaets hovednummer</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="linkCompanySearch" class="form-label">Søg firma</label>
|
||||||
|
<input id="linkCompanySearch" type="text" class="form-control" placeholder="Søg firmanavn (min. 2 tegn)" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div id="linkCompanySelected" class="alert alert-secondary py-2 mb-3">Intet firma valgt</div>
|
||||||
|
<div id="linkCompanyResults" class="list-group"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" id="linkContactConfirm" class="btn btn-primary" disabled>Knyt kontakt</button>
|
||||||
|
<button type="button" id="createContactConfirm" class="btn btn-success">Opret og knyt</button>
|
||||||
|
<button type="button" id="createCompanyCaseConfirm" class="btn btn-warning">Opret sag på firma</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="linkSagModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="linkSagModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@ -124,6 +187,20 @@ const linkSagState = {
|
|||||||
searchToken: 0,
|
searchToken: 0,
|
||||||
modal: null
|
modal: null
|
||||||
};
|
};
|
||||||
|
const linkContactState = {
|
||||||
|
callId: null,
|
||||||
|
selectedContactId: null,
|
||||||
|
selectedLabel: '',
|
||||||
|
searchTimer: null,
|
||||||
|
companySearchTimer: null,
|
||||||
|
searchToken: 0,
|
||||||
|
companySearchToken: 0,
|
||||||
|
modal: null,
|
||||||
|
number: '',
|
||||||
|
mode: 'contact',
|
||||||
|
selectedCompanyId: null,
|
||||||
|
selectedCompanyLabel: ''
|
||||||
|
};
|
||||||
|
|
||||||
async function ensureCurrentUserId() {
|
async function ensureCurrentUserId() {
|
||||||
if (telefoniCurrentUserId !== null) return telefoniCurrentUserId;
|
if (telefoniCurrentUserId !== null) return telefoniCurrentUserId;
|
||||||
@ -147,6 +224,385 @@ function getLinkSagModalInstance() {
|
|||||||
return linkSagState.modal;
|
return linkSagState.modal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLinkContactModalInstance() {
|
||||||
|
if (!linkContactState.modal) {
|
||||||
|
const el = document.getElementById('linkContactModal');
|
||||||
|
if (!el || !window.bootstrap) return null;
|
||||||
|
linkContactState.modal = new bootstrap.Modal(el);
|
||||||
|
}
|
||||||
|
return linkContactState.modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLinkContactSelected(contactId, label) {
|
||||||
|
const selected = document.getElementById('linkContactSelected');
|
||||||
|
const confirmBtn = document.getElementById('linkContactConfirm');
|
||||||
|
const numericContactId = Number(contactId);
|
||||||
|
if (!Number.isInteger(numericContactId) || numericContactId <= 0) {
|
||||||
|
linkContactState.selectedContactId = null;
|
||||||
|
linkContactState.selectedLabel = '';
|
||||||
|
if (selected) {
|
||||||
|
selected.className = 'alert alert-secondary py-2 mb-3';
|
||||||
|
selected.textContent = 'Ingen kontakt valgt';
|
||||||
|
}
|
||||||
|
if (confirmBtn) confirmBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkContactState.selectedContactId = numericContactId;
|
||||||
|
linkContactState.selectedLabel = String(label || `Kontakt #${numericContactId}`);
|
||||||
|
if (selected) {
|
||||||
|
selected.className = 'alert alert-success py-2 mb-3';
|
||||||
|
selected.textContent = `Valgt: ${linkContactState.selectedLabel} (ID: ${numericContactId})`;
|
||||||
|
}
|
||||||
|
if (confirmBtn) confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLinkCompanySelected(companyId, label) {
|
||||||
|
const selected = document.getElementById('linkCompanySelected');
|
||||||
|
const numericCompanyId = Number(companyId);
|
||||||
|
if (!Number.isInteger(numericCompanyId) || numericCompanyId <= 0) {
|
||||||
|
linkContactState.selectedCompanyId = null;
|
||||||
|
linkContactState.selectedCompanyLabel = '';
|
||||||
|
if (selected) {
|
||||||
|
selected.className = 'alert alert-secondary py-2 mb-3';
|
||||||
|
selected.textContent = 'Intet firma valgt';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkContactState.selectedCompanyId = numericCompanyId;
|
||||||
|
linkContactState.selectedCompanyLabel = String(label || `Firma #${numericCompanyId}`);
|
||||||
|
if (selected) {
|
||||||
|
selected.className = 'alert alert-success py-2 mb-3';
|
||||||
|
selected.textContent = `Valgt: ${linkContactState.selectedCompanyLabel} (ID: ${numericCompanyId})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLinkContactResults(results) {
|
||||||
|
const container = document.getElementById('linkContactResults');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
container.innerHTML = '<div class="alert alert-light border mb-0">Ingen kontakter fundet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = (results || []).map((item) => {
|
||||||
|
const cid = Number(item.id);
|
||||||
|
const name = `${item.first_name || ''} ${item.last_name || ''}`.trim() || `Kontakt #${cid}`;
|
||||||
|
const email = String(item.email || '-');
|
||||||
|
const phone = String(item.mobile || item.phone || '-');
|
||||||
|
return `
|
||||||
|
<button type="button" class="list-group-item list-group-item-action" data-contact-id="${cid}" data-contact-label="${escapeHtml(name)}">
|
||||||
|
<div class="fw-semibold">${escapeHtml(name)}</div>
|
||||||
|
<div class="small text-muted">${escapeHtml(email)} · ${escapeHtml(phone)}</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('[data-contact-id]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const cid = Number(btn.getAttribute('data-contact-id'));
|
||||||
|
const label = btn.getAttribute('data-contact-label') || `Kontakt #${cid}`;
|
||||||
|
setLinkContactSelected(cid, label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchContacts(query) {
|
||||||
|
const token = ++linkContactState.searchToken;
|
||||||
|
const container = document.getElementById('linkContactResults');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
|
||||||
|
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' });
|
||||||
|
if (token !== linkContactState.searchToken) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const results = Array.isArray(data?.contacts) ? data.contacts : [];
|
||||||
|
renderLinkContactResults(results);
|
||||||
|
} catch (e) {
|
||||||
|
if (token !== linkContactState.searchToken) return;
|
||||||
|
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Fejl under søgning</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchCallContact(callId, contactId) {
|
||||||
|
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ kontakt_id: contactId })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
throw new Error(t || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompanyResults(results) {
|
||||||
|
const container = document.getElementById('linkCompanyResults');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
container.innerHTML = '<div class="alert alert-light border mb-0">Ingen firmaer fundet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = (results || []).map((item) => {
|
||||||
|
const cid = Number(item.id);
|
||||||
|
const name = String(item.name || `Firma #${cid}`);
|
||||||
|
const cvr = String(item.cvr_nummer || item.cvr_number || '-');
|
||||||
|
return `
|
||||||
|
<button type="button" class="list-group-item list-group-item-action" data-company-id="${cid}" data-company-label="${escapeHtml(name)}">
|
||||||
|
<div class="fw-semibold">${escapeHtml(name)}</div>
|
||||||
|
<div class="small text-muted">CVR: ${escapeHtml(cvr)}</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('[data-company-id]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const cid = Number(btn.getAttribute('data-company-id'));
|
||||||
|
const label = btn.getAttribute('data-company-label') || `Firma #${cid}`;
|
||||||
|
setLinkCompanySelected(cid, label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCompanies(query) {
|
||||||
|
const token = ++linkContactState.companySearchToken;
|
||||||
|
const container = document.getElementById('linkCompanyResults');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query || '')}`, { credentials: 'include' });
|
||||||
|
if (token !== linkContactState.companySearchToken) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge firmaer</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
renderCompanyResults(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
if (token !== linkContactState.companySearchToken) return;
|
||||||
|
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Fejl under søgning</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLinkContactModalEvents() {
|
||||||
|
const searchInput = document.getElementById('linkContactSearch');
|
||||||
|
const companySearchInput = document.getElementById('linkCompanySearch');
|
||||||
|
const confirmBtn = document.getElementById('linkContactConfirm');
|
||||||
|
const createBtn = document.getElementById('createContactConfirm');
|
||||||
|
const createCompanyCaseBtn = document.getElementById('createCompanyCaseConfirm');
|
||||||
|
const modalEl = document.getElementById('linkContactModal');
|
||||||
|
if (!searchInput || !companySearchInput || !confirmBtn || !createBtn || !createCompanyCaseBtn) return;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
if (linkContactState.searchTimer) clearTimeout(linkContactState.searchTimer);
|
||||||
|
const q = String(searchInput.value || '').trim();
|
||||||
|
linkContactState.searchTimer = setTimeout(() => {
|
||||||
|
if (!q) {
|
||||||
|
renderLinkContactResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchContacts(q);
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
companySearchInput.addEventListener('input', () => {
|
||||||
|
if (linkContactState.companySearchTimer) clearTimeout(linkContactState.companySearchTimer);
|
||||||
|
const q = String(companySearchInput.value || '').trim();
|
||||||
|
linkContactState.companySearchTimer = setTimeout(() => {
|
||||||
|
if (q.length < 2) {
|
||||||
|
renderCompanyResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchCompanies(q);
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', async () => {
|
||||||
|
if (!linkContactState.callId || !linkContactState.selectedContactId) return;
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await patchCallContact(linkContactState.callId, linkContactState.selectedContactId);
|
||||||
|
const modal = getLinkContactModalInstance();
|
||||||
|
if (modal) modal.hide();
|
||||||
|
await loadCalls();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Kunne ikke knytte kontakt: ' + (error?.message || 'ukendt fejl'));
|
||||||
|
} finally {
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createBtn.addEventListener('click', async () => {
|
||||||
|
const firstName = String(document.getElementById('newContactFirstName')?.value || '').trim();
|
||||||
|
const lastName = String(document.getElementById('newContactLastName')?.value || '').trim();
|
||||||
|
const email = String(document.getElementById('newContactEmail')?.value || '').trim();
|
||||||
|
const title = String(document.getElementById('newContactTitle')?.value || '').trim();
|
||||||
|
const phoneInput = String(document.getElementById('newContactPhone')?.value || '').trim() || linkContactState.number;
|
||||||
|
|
||||||
|
if (!firstName) {
|
||||||
|
alert('Fornavn er påkrævet for at oprette kontakt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkContactState.callId) {
|
||||||
|
alert('Opkald mangler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createBtn.disabled = true;
|
||||||
|
createBtn.textContent = 'Opretter...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
email: email || null,
|
||||||
|
phone: phoneInput || null,
|
||||||
|
title: title || null,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
throw new Error(t || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await res.json();
|
||||||
|
const contactId = Number(created?.id || 0);
|
||||||
|
if (!Number.isInteger(contactId) || contactId <= 0) {
|
||||||
|
throw new Error('Kontakt blev oprettet men ID mangler');
|
||||||
|
}
|
||||||
|
|
||||||
|
await patchCallContact(linkContactState.callId, contactId);
|
||||||
|
const modal = getLinkContactModalInstance();
|
||||||
|
if (modal) modal.hide();
|
||||||
|
await loadCalls();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Kunne ikke oprette kontakt: ' + (error?.message || 'ukendt fejl'));
|
||||||
|
} finally {
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.textContent = 'Opret og knyt';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createCompanyCaseBtn.addEventListener('click', async () => {
|
||||||
|
if (!linkContactState.callId) {
|
||||||
|
alert('Opkald mangler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkContactState.selectedCompanyId) {
|
||||||
|
alert('Vælg et firma først');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberForTitle = linkContactState.number || 'ukendt nummer';
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('customer_id', String(linkContactState.selectedCompanyId));
|
||||||
|
qs.set('telefoni_opkald_id', String(linkContactState.callId));
|
||||||
|
qs.set('title', `Telefonsamtale – ${numberForTitle}`);
|
||||||
|
qs.set('description', `Opkald fra firmaets hovednummer: ${numberForTitle}`);
|
||||||
|
window.location.href = `/sag/new?${qs.toString()}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modalEl) {
|
||||||
|
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||||
|
if (linkContactState.searchTimer) clearTimeout(linkContactState.searchTimer);
|
||||||
|
if (linkContactState.companySearchTimer) clearTimeout(linkContactState.companySearchTimer);
|
||||||
|
linkContactState.searchTimer = null;
|
||||||
|
linkContactState.companySearchTimer = null;
|
||||||
|
linkContactState.searchToken++;
|
||||||
|
linkContactState.companySearchToken++;
|
||||||
|
linkContactState.callId = null;
|
||||||
|
linkContactState.number = '';
|
||||||
|
linkContactState.mode = 'contact';
|
||||||
|
setLinkContactSelected(null, '');
|
||||||
|
setLinkCompanySelected(null, '');
|
||||||
|
searchInput.value = '';
|
||||||
|
companySearchInput.value = '';
|
||||||
|
document.getElementById('newContactFirstName').value = '';
|
||||||
|
document.getElementById('newContactLastName').value = '';
|
||||||
|
document.getElementById('newContactPhone').value = '';
|
||||||
|
document.getElementById('newContactEmail').value = '';
|
||||||
|
document.getElementById('newContactTitle').value = '';
|
||||||
|
const results = document.getElementById('linkContactResults');
|
||||||
|
const companyResults = document.getElementById('linkCompanyResults');
|
||||||
|
const ctx = document.getElementById('linkContactContext');
|
||||||
|
if (results) results.innerHTML = '';
|
||||||
|
if (companyResults) companyResults.innerHTML = '';
|
||||||
|
if (ctx) ctx.textContent = 'Opkald: -';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLinkContactModal(callId, mode = 'contact') {
|
||||||
|
const numericCallId = Number(callId);
|
||||||
|
const call = telefoniCallMap.get(numericCallId);
|
||||||
|
linkContactState.callId = numericCallId;
|
||||||
|
linkContactState.number = String(call?.display_number || call?.ekstern_nummer || '').trim();
|
||||||
|
linkContactState.mode = mode;
|
||||||
|
|
||||||
|
const ctx = document.getElementById('linkContactContext');
|
||||||
|
const searchInput = document.getElementById('linkContactSearch');
|
||||||
|
const companySearchInput = document.getElementById('linkCompanySearch');
|
||||||
|
const phoneInput = document.getElementById('newContactPhone');
|
||||||
|
const firstNameInput = document.getElementById('newContactFirstName');
|
||||||
|
const label = call
|
||||||
|
? `${call.direction === 'outbound' ? 'Udgående' : 'Indgående'} · ${linkContactState.number || '-'} · ${call.started_at ? new Date(call.started_at).toLocaleString('da-DK') : '-'}`
|
||||||
|
: `Opkald #${numericCallId}`;
|
||||||
|
|
||||||
|
if (ctx) ctx.textContent = `Opkald: ${label}`;
|
||||||
|
if (searchInput) searchInput.value = linkContactState.number;
|
||||||
|
if (companySearchInput) companySearchInput.value = '';
|
||||||
|
if (phoneInput) phoneInput.value = linkContactState.number;
|
||||||
|
if (firstNameInput && !firstNameInput.value) {
|
||||||
|
firstNameInput.value = 'Ukendt';
|
||||||
|
}
|
||||||
|
setLinkContactSelected(null, '');
|
||||||
|
setLinkCompanySelected(null, '');
|
||||||
|
renderLinkContactResults([]);
|
||||||
|
renderCompanyResults([]);
|
||||||
|
|
||||||
|
const modal = getLinkContactModalInstance();
|
||||||
|
if (modal) modal.show();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mode === 'company') {
|
||||||
|
companySearchInput?.focus();
|
||||||
|
} else {
|
||||||
|
searchInput?.focus();
|
||||||
|
}
|
||||||
|
if (linkContactState.number) {
|
||||||
|
searchContacts(linkContactState.number);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkContact(callId) {
|
||||||
|
if (!confirm('Fjern link til kontakt for dette opkald?')) return;
|
||||||
|
try {
|
||||||
|
await patchCallContact(callId, null);
|
||||||
|
await loadCalls();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Kunne ikke fjerne kontakt-link: ' + (error?.message || 'ukendt fejl'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setLinkSagSelected(sagId, label) {
|
function setLinkSagSelected(sagId, label) {
|
||||||
const selected = document.getElementById('linkSagSelected');
|
const selected = document.getElementById('linkSagSelected');
|
||||||
const confirmBtn = document.getElementById('linkSagConfirm');
|
const confirmBtn = document.getElementById('linkSagConfirm');
|
||||||
@ -408,8 +864,15 @@ async function loadCalls() {
|
|||||||
: '-';
|
: '-';
|
||||||
|
|
||||||
const contactHtml = r.kontakt_id
|
const contactHtml = r.kontakt_id
|
||||||
? `<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
|
? `<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
: '<span class="text-muted">-</span>';
|
<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})">Skift</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${Number(r.id)})">Fjern</button>
|
||||||
|
</div>
|
||||||
|
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
|
||||||
|
: `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})" title="Vælg kontakt/firma">
|
||||||
|
<i class="bi bi-three-dots"></i>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
|
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
|
||||||
const createQs = new URLSearchParams();
|
const createQs = new URLSearchParams();
|
||||||
@ -500,6 +963,7 @@ async function unlinkCase(callId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
initLinkContactModalEvents();
|
||||||
initLinkSagModalEvents();
|
initLinkSagModalEvents();
|
||||||
const userFilter = document.getElementById('filterUser');
|
const userFilter = document.getElementById('filterUser');
|
||||||
const fromFilter = document.getElementById('filterFrom');
|
const fromFilter = document.getElementById('filterFrom');
|
||||||
|
|||||||
@ -129,13 +129,14 @@ class EconomicService:
|
|||||||
|
|
||||||
# ========== CUSTOMER MANAGEMENT ==========
|
# ========== CUSTOMER MANAGEMENT ==========
|
||||||
|
|
||||||
async def get_customers(self, page: int = 0, page_size: int = 1000) -> List[Dict]:
|
async def get_customers(self, page: int = 0, page_size: int = 1000, strict: bool = False) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get customers from e-conomic
|
Get customers from e-conomic
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page: Page number (0-indexed)
|
page: Page number (0-indexed)
|
||||||
page_size: Number of records per page
|
page_size: Number of records per page
|
||||||
|
strict: Raise exception on non-200 responses instead of returning []
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of customer records with customerNumber, corporateIdentificationNumber, name
|
List of customer records with customerNumber, corporateIdentificationNumber, name
|
||||||
@ -155,9 +156,25 @@ class EconomicService:
|
|||||||
else:
|
else:
|
||||||
error = await response.text()
|
error = await response.text()
|
||||||
logger.error(f"❌ Failed to fetch customers: {response.status} - {error}")
|
logger.error(f"❌ Failed to fetch customers: {response.status} - {error}")
|
||||||
|
if strict:
|
||||||
|
detail = f"e-conomic kundehentning fejlede ({response.status})"
|
||||||
|
try:
|
||||||
|
payload = json.loads(error)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
message = payload.get('message') or payload.get('developerHint')
|
||||||
|
code = payload.get('errorCode')
|
||||||
|
if message and code:
|
||||||
|
detail = f"e-conomic kundehentning fejlede: {message} ({code})"
|
||||||
|
elif message:
|
||||||
|
detail = f"e-conomic kundehentning fejlede: {message}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(detail)
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error fetching customers from e-conomic: {e}")
|
logger.error(f"❌ Error fetching customers from e-conomic: {e}")
|
||||||
|
if strict:
|
||||||
|
raise
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def search_customer_by_cvr(self, cvr: str) -> Optional[Dict]:
|
async def search_customer_by_cvr(self, cvr: str) -> Optional[Dict]:
|
||||||
|
|||||||
@ -960,9 +960,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button class="btn btn-success" onclick="syncFromEconomic()" id="btnSyncEconomic">
|
<button type="button" class="btn btn-success" onclick="syncFromEconomic()" id="btnSyncEconomic">
|
||||||
<i class="bi bi-download me-2"></i>Sync Firmaer fra e-conomic
|
<i class="bi bi-download me-2"></i>Sync Firmaer fra e-conomic
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm" onclick="dryRunEconomicSync()" id="btnDryRunEconomic">
|
||||||
|
<i class="bi bi-beaker me-2"></i>Dry-run preview (ingen writes)
|
||||||
|
</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="btnSyncCvrEconomic" disabled>
|
<button class="btn btn-outline-secondary btn-sm" id="btnSyncCvrEconomic" disabled>
|
||||||
<i class="bi bi-pause-circle me-2"></i>CVR→e-conomic midlertidigt deaktiveret
|
<i class="bi bi-pause-circle me-2"></i>CVR→e-conomic midlertidigt deaktiveret
|
||||||
</button>
|
</button>
|
||||||
@ -1059,6 +1062,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="economicDryRunModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-beaker me-2"></i>e-conomic Dry-run Preview</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="economicDryRunSummary" class="mb-3"></div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-success h-100">
|
||||||
|
<div class="card-header bg-success-subtle fw-semibold">Ville blive oprettet</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="economicDryRunCreateList" class="small"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-primary h-100">
|
||||||
|
<div class="card-header bg-primary-subtle fw-semibold">Ville blive opdateret</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="economicDryRunUpdateList" class="small"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-danger h-100">
|
||||||
|
<div class="card-header bg-danger-subtle fw-semibold">Konflikter</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="economicDryRunConflictList" class="small"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-secondary h-100">
|
||||||
|
<div class="card-header bg-secondary-subtle fw-semibold">Sprunget over</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="economicDryRunSkippedList" class="small"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="economicDryRunContactsNote" class="alert alert-warning mt-3 mb-0 small"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" class="btn btn-success" onclick="syncFromEconomic()">
|
||||||
|
<i class="bi bi-play-circle me-2"></i>Kør rigtig sync nu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- /div>
|
<!-- /div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -4354,6 +4412,14 @@ async function parseApiError(response, fallbackMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (String(detailMessage).includes('AgreementDeregistered')) {
|
||||||
|
return 'e-conomic aftalen er afregistreret. Opret nyt grant token i e-conomic, opdater ECONOMIC_AGREEMENT_GRANT_TOKEN i .env og genstart API.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(detailMessage).includes('InvalidGrantToken')) {
|
||||||
|
return 'e-conomic grant token er ugyldigt. Opdater ECONOMIC_AGREEMENT_GRANT_TOKEN i .env og genstart API.';
|
||||||
|
}
|
||||||
|
|
||||||
return detailMessage;
|
return detailMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4665,6 +4731,151 @@ async function syncFromEconomic() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDryRunList(items, formatter) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return '<div class="text-muted">Ingen</div>';
|
||||||
|
}
|
||||||
|
return `<div class="list-group list-group-flush">${items.map(item => `
|
||||||
|
<div class="list-group-item px-0">${formatter(item)}</div>
|
||||||
|
`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEconomicDryRunModal() {
|
||||||
|
const modalEl = document.getElementById('economicDryRunModal');
|
||||||
|
if (!modalEl || !window.bootstrap) return null;
|
||||||
|
return bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dryRunEconomicSync() {
|
||||||
|
const btn = document.getElementById('btnDryRunEconomic');
|
||||||
|
const modal = ensureEconomicDryRunModal();
|
||||||
|
const summary = document.getElementById('economicDryRunSummary');
|
||||||
|
const createList = document.getElementById('economicDryRunCreateList');
|
||||||
|
const updateList = document.getElementById('economicDryRunUpdateList');
|
||||||
|
const conflictList = document.getElementById('economicDryRunConflictList');
|
||||||
|
const skippedList = document.getElementById('economicDryRunSkippedList');
|
||||||
|
const contactsNote = document.getElementById('economicDryRunContactsNote');
|
||||||
|
|
||||||
|
if (!btn || !summary || !createList || !updateList || !conflictList || !skippedList || !contactsNote) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Kører dry-run...';
|
||||||
|
|
||||||
|
summary.innerHTML = '<div class="text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Beregner preview...</div>';
|
||||||
|
createList.innerHTML = '';
|
||||||
|
updateList.innerHTML = '';
|
||||||
|
conflictList.innerHTML = '';
|
||||||
|
skippedList.innerHTML = '';
|
||||||
|
contactsNote.textContent = '';
|
||||||
|
|
||||||
|
if (modal) modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
addSyncLogEntry('e-conomic Dry-run Startet', 'Beregner hvad sync ville ændre uden at skrive til databasen...', 'info');
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
'/api/v1/system/sync/economic/dry-run',
|
||||||
|
'/api/v1/system/sync/economic/dryrun',
|
||||||
|
'/api/v1/system/sync/economic/preview'
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = null;
|
||||||
|
let lastNetworkError = null;
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If endpoint exists (even unauthorized), stop fallback chain.
|
||||||
|
if (response.status !== 404) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (networkError) {
|
||||||
|
lastNetworkError = networkError;
|
||||||
|
response = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error(lastNetworkError?.message || 'Kunne ikke kontakte serveren');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = await parseApiError(response, 'Dry-run fejlede');
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const preview = result.preview || {};
|
||||||
|
const trunc = preview.truncated || {};
|
||||||
|
|
||||||
|
summary.innerHTML = `
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<div class="fw-semibold mb-2">Dry-run resultat</div>
|
||||||
|
<div class="small">
|
||||||
|
Behandlet: <strong>${result.total_processed || 0}</strong> ·
|
||||||
|
Oprettes: <strong>${result.created || 0}</strong> ·
|
||||||
|
Opdateres: <strong>${result.updated || 0}</strong> ·
|
||||||
|
Konflikter: <strong>${result.conflicts || 0}</strong> ·
|
||||||
|
Sprunget over: <strong>${result.skipped || 0}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
Viser op til ${trunc.limit_per_list || 25} linjer pr. sektion.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
createList.innerHTML = renderDryRunList(preview.would_create, (item) => {
|
||||||
|
const name = item.name || '-';
|
||||||
|
const num = item.economic_customer_number || '-';
|
||||||
|
const cvr = item.cvr_number || '-';
|
||||||
|
return `<strong>${name}</strong><br><span class="text-muted">e-conomic #${num} · CVR ${cvr}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
updateList.innerHTML = renderDryRunList(preview.would_update, (item) => {
|
||||||
|
const name = item.customer_name || '-';
|
||||||
|
const id = item.customer_id || '-';
|
||||||
|
const num = item.economic_customer_number || '-';
|
||||||
|
return `<strong>${name}</strong><br><span class="text-muted">Lokal ID ${id} · e-conomic #${num}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
conflictList.innerHTML = renderDryRunList(preview.conflicts, (item) => {
|
||||||
|
const name = item.name || '-';
|
||||||
|
const num = item.economic_customer_number || '-';
|
||||||
|
const ids = (item.duplicate_customer_ids || []).join(', ') || '-';
|
||||||
|
return `<strong>${name}</strong><br><span class="text-danger">e-conomic #${num} matcher flere lokale kunder: ${ids}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
skippedList.innerHTML = renderDryRunList(preview.skipped, (item) => {
|
||||||
|
const name = item.name || '-';
|
||||||
|
const reason = item.reason || 'Ukendt årsag';
|
||||||
|
return `<strong>${name}</strong><br><span class="text-muted">${reason}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
contactsNote.textContent = result.contacts?.message || 'Kontakt-sync note ikke tilgængelig.';
|
||||||
|
|
||||||
|
addSyncLogEntry(
|
||||||
|
'e-conomic Dry-run Fuldført',
|
||||||
|
`Behandlet: ${result.total_processed || 0} | Oprettes: ${result.created || 0} | Opdateres: ${result.updated || 0} | Konflikter: ${result.conflicts || 0}`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
summary.innerHTML = `<div class="alert alert-danger mb-0">Dry-run fejlede: ${error.message}</div>`;
|
||||||
|
addSyncLogEntry('e-conomic Dry-run Fejl', error.message, 'error');
|
||||||
|
showNotification('Dry-run fejl: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-beaker me-2"></i>Dry-run preview (ingen writes)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncCvrToEconomic() {
|
async function syncCvrToEconomic() {
|
||||||
showNotification('CVR→e-conomic sync er midlertidigt deaktiveret.', 'info');
|
showNotification('CVR→e-conomic sync er midlertidigt deaktiveret.', 'info');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -17,6 +17,9 @@
|
|||||||
--text-secondary: #6c757d;
|
--text-secondary: #6c757d;
|
||||||
--accent: #0f4c75;
|
--accent: #0f4c75;
|
||||||
--accent-light: #eef2f5;
|
--accent-light: #eef2f5;
|
||||||
|
--frame-border: rgba(15, 76, 117, 0.18);
|
||||||
|
--frame-border-strong: rgba(15, 76, 117, 0.28);
|
||||||
|
--frame-shadow: 0 4px 12px rgba(15, 76, 117, 0.10);
|
||||||
--border-radius: 12px;
|
--border-radius: 12px;
|
||||||
--bottom-bar-height: 50px;
|
--bottom-bar-height: 50px;
|
||||||
--bottom-bar-expanded-height: 50vh;
|
--bottom-bar-expanded-height: 50vh;
|
||||||
@ -32,6 +35,9 @@
|
|||||||
--text-secondary: #adb5bd;
|
--text-secondary: #adb5bd;
|
||||||
--accent: #3d8bfd; /* Lighter blue for dark mode */
|
--accent: #3d8bfd; /* Lighter blue for dark mode */
|
||||||
--accent-light: #373b3e;
|
--accent-light: #373b3e;
|
||||||
|
--frame-border: rgba(117, 167, 204, 0.30);
|
||||||
|
--frame-border-strong: rgba(117, 167, 204, 0.45);
|
||||||
|
--frame-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -566,15 +572,66 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border: none;
|
border: 2px solid var(--frame-border-strong);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
box-shadow: var(--frame-shadow);
|
||||||
transition: transform 0.2s, background-color 0.3s;
|
transition: transform 0.2s, background-color 0.3s, border-color 0.2s, box-shadow 0.2s;
|
||||||
background: var(--bg-card);
|
background: linear-gradient(165deg, color-mix(in srgb, var(--accent) 6%, var(--bg-card)) 0%, var(--bg-card) 100%);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 76, 117, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .card-header {
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, #d1d5db);
|
||||||
|
background: color-mix(in srgb, var(--accent) 7%, var(--bg-card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-priority-low {
|
||||||
|
--module-accent: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-priority-normal {
|
||||||
|
--module-accent: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-priority-high {
|
||||||
|
--module-accent: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-priority-critical {
|
||||||
|
--module-accent: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card,
|
||||||
|
.left-module-card,
|
||||||
|
.right-module-card {
|
||||||
|
border-color: var(--frame-border-strong) !important;
|
||||||
|
border-left-color: var(--module-accent, var(--accent)) !important;
|
||||||
|
background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, var(--accent)) 6%, var(--bg-card)) 0%, var(--bg-card) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .card {
|
||||||
|
border-color: var(--frame-border-strong);
|
||||||
|
box-shadow: var(--frame-shadow);
|
||||||
|
background: linear-gradient(165deg, color-mix(in srgb, var(--accent) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .card .card-header {
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--accent) 45%, #4b5563);
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, rgba(18, 28, 40, 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .module-card,
|
||||||
|
[data-bs-theme="dark"] .left-module-card,
|
||||||
|
[data-bs-theme="dark"] .right-module-card {
|
||||||
|
border-color: var(--frame-border-strong) !important;
|
||||||
|
border-left-color: var(--module-accent, var(--accent)) !important;
|
||||||
|
background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, #69a6d5) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h3 {
|
.stat-card h3 {
|
||||||
@ -702,6 +759,10 @@
|
|||||||
</style>
|
</style>
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
{% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
|
||||||
|
{% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %}
|
||||||
|
{% set _client_ip = (request.headers.get('cf-connecting-ip') if request and request.headers else '') or (request.headers.get('true-client-ip') if request and request.headers else '') or _xff_first or (request.headers.get('x-real-ip') if request and request.headers else '') or (request.client.host if request and request.client else '') %}
|
||||||
|
{% set _can_click_to_call = _client_ip.startswith('172.16.31.') %}
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
<nav class="navbar navbar-expand-lg fixed-top">
|
||||||
@ -752,6 +813,7 @@
|
|||||||
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
|
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
||||||
|
<li><a class="dropdown-item py-2" href="/support/fedex"><i class="bi bi-truck me-2"></i>FedEx Overblik</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
|
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
||||||
@ -1207,6 +1269,59 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
|
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
||||||
|
|
||||||
|
if (!window.BMC_CAN_CLICK_TO_CALL) {
|
||||||
|
const RING_OP_TEXT = /\bring\s*op\b/i;
|
||||||
|
const callSelector = [
|
||||||
|
'button[onclick*="ViaYealink"]',
|
||||||
|
'a[onclick*="ViaYealink"]',
|
||||||
|
'button[onclick*="testTelefoniCall"]',
|
||||||
|
'#telefoniTestBtn',
|
||||||
|
'[data-call-action]'
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const isRingCallButton = (el) => {
|
||||||
|
if (!el || !(el instanceof Element)) return false;
|
||||||
|
if (el.matches(callSelector)) return true;
|
||||||
|
|
||||||
|
const text = (el.textContent || '').trim();
|
||||||
|
if (RING_OP_TEXT.test(text)) return true;
|
||||||
|
|
||||||
|
const onclick = (el.getAttribute('onclick') || '').toLowerCase();
|
||||||
|
return onclick.includes('click-to-call') || onclick.includes('viayealink') || onclick.includes('testtelefonicall');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideCallButtons = (root) => {
|
||||||
|
const scope = root && root.querySelectorAll ? root : document;
|
||||||
|
scope.querySelectorAll('button, a, [role="button"]').forEach((el) => {
|
||||||
|
if (!isRingCallButton(el)) return;
|
||||||
|
if (el.dataset.callHiddenByIp === '1') return;
|
||||||
|
el.dataset.callHiddenByIp = '1';
|
||||||
|
el.style.display = 'none';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
hideCallButtons(document);
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (!(node instanceof Element)) return;
|
||||||
|
if (isRingCallButton(node)) {
|
||||||
|
node.dataset.callHiddenByIp = '1';
|
||||||
|
node.style.display = 'none';
|
||||||
|
}
|
||||||
|
hideCallButtons(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
const htmlElement = document.documentElement;
|
const htmlElement = document.documentElement;
|
||||||
const icon = darkModeToggle.querySelector('i');
|
const icon = darkModeToggle.querySelector('i');
|
||||||
|
|||||||
@ -36,6 +36,7 @@ from typing import Dict, Any
|
|||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
from app.core.auth_dependencies import require_any_permission
|
from app.core.auth_dependencies import require_any_permission
|
||||||
from app.services.vtiger_service import get_vtiger_service
|
from app.services.vtiger_service import get_vtiger_service
|
||||||
|
import aiohttp
|
||||||
import re
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -54,6 +55,266 @@ def normalize_name(name: str) -> str:
|
|||||||
return name.lower().strip()
|
return name.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def _economic_sync_apply_or_preview(apply_changes: bool) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build economic sync plan and optionally apply changes.
|
||||||
|
|
||||||
|
If apply_changes=False the function does a pure dry-run and returns a detailed plan.
|
||||||
|
If apply_changes=True it performs the same plan as DB writes.
|
||||||
|
"""
|
||||||
|
from app.services.economic_service import EconomicService
|
||||||
|
|
||||||
|
economic = EconomicService()
|
||||||
|
|
||||||
|
# Preflight check so UI gets a clear reason instead of a silent zero-result dry-run.
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{economic.api_url}/self",
|
||||||
|
headers=economic._get_headers(),
|
||||||
|
) as preflight_response:
|
||||||
|
if preflight_response.status != 200:
|
||||||
|
raw = await preflight_response.text()
|
||||||
|
logger.error("❌ e-conomic preflight failed: %s - %s", preflight_response.status, raw)
|
||||||
|
detail = f"e-conomic forbindelse fejlede ({preflight_response.status})"
|
||||||
|
try:
|
||||||
|
payload = await preflight_response.json(content_type=None)
|
||||||
|
message = payload.get("message") if isinstance(payload, dict) else None
|
||||||
|
code = payload.get("errorCode") if isinstance(payload, dict) else None
|
||||||
|
if code == "AgreementDeregistered":
|
||||||
|
detail = (
|
||||||
|
"e-conomic aftalen er afregistreret (AgreementDeregistered). "
|
||||||
|
"Opret nyt grant token i e-conomic, opdater ECONOMIC_AGREEMENT_GRANT_TOKEN i .env "
|
||||||
|
"og genstart API containeren."
|
||||||
|
)
|
||||||
|
elif code == "InvalidGrantToken":
|
||||||
|
detail = (
|
||||||
|
"e-conomic grant token er ugyldigt (InvalidGrantToken). "
|
||||||
|
"Opdater ECONOMIC_AGREEMENT_GRANT_TOKEN i .env og genstart API containeren."
|
||||||
|
)
|
||||||
|
elif message and code:
|
||||||
|
detail = f"e-conomic forbindelse fejlede: {message} ({code})"
|
||||||
|
elif message:
|
||||||
|
detail = f"e-conomic forbindelse fejlede: {message}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=502, detail=detail)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ e-conomic preflight exception: %s", e)
|
||||||
|
raise HTTPException(status_code=502, detail="Kunne ikke kontakte e-conomic API")
|
||||||
|
|
||||||
|
# Get all customers from e-conomic (max 1000 per page)
|
||||||
|
all_customers = []
|
||||||
|
page = 0
|
||||||
|
while True:
|
||||||
|
customers = await economic.get_customers(page=page, page_size=1000, strict=True)
|
||||||
|
if not customers:
|
||||||
|
break
|
||||||
|
all_customers.extend(customers)
|
||||||
|
page += 1
|
||||||
|
if len(customers) < 1000:
|
||||||
|
break
|
||||||
|
|
||||||
|
economic_customers = all_customers
|
||||||
|
logger.info("📥 Fetched %s customers from e-conomic (%s pages)", len(economic_customers), page)
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
conflict_count = 0
|
||||||
|
|
||||||
|
would_create: list[Dict[str, Any]] = []
|
||||||
|
would_update: list[Dict[str, Any]] = []
|
||||||
|
conflicts: list[Dict[str, Any]] = []
|
||||||
|
skipped: list[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for eco_customer in economic_customers:
|
||||||
|
customer_number_raw = eco_customer.get('customerNumber')
|
||||||
|
customer_number = str(customer_number_raw).strip() if customer_number_raw is not None else None
|
||||||
|
cvr = eco_customer.get('corporateIdentificationNumber')
|
||||||
|
name = eco_customer.get('name', '').strip()
|
||||||
|
address = eco_customer.get('address', '')
|
||||||
|
city = eco_customer.get('city', '')
|
||||||
|
zip_code = eco_customer.get('zip', '')
|
||||||
|
country = eco_customer.get('country', 'DK')
|
||||||
|
email = eco_customer.get('email', '')
|
||||||
|
website = eco_customer.get('website', '')
|
||||||
|
|
||||||
|
if not customer_number or not name:
|
||||||
|
skipped_count += 1
|
||||||
|
skipped.append({
|
||||||
|
"economic_customer_number": customer_number,
|
||||||
|
"name": name or "(mangler navn)",
|
||||||
|
"reason": "Manglende kundenummer eller navn"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Clean CVR
|
||||||
|
if cvr:
|
||||||
|
cvr = re.sub(r'\D', '', str(cvr))[:8]
|
||||||
|
if len(cvr) != 8:
|
||||||
|
cvr = None
|
||||||
|
|
||||||
|
# Clean country code (max 2 chars for ISO codes)
|
||||||
|
if country:
|
||||||
|
country = str(country).strip().upper()[:2]
|
||||||
|
if not country:
|
||||||
|
country = 'DK'
|
||||||
|
else:
|
||||||
|
country = 'DK'
|
||||||
|
|
||||||
|
email_domain = email.split('@')[-1] if '@' in email else None
|
||||||
|
|
||||||
|
# Strict matching: ONLY match by economic_customer_number
|
||||||
|
existing = execute_query(
|
||||||
|
"SELECT id, name FROM customers WHERE economic_customer_number = %s ORDER BY id",
|
||||||
|
(customer_number,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(existing) > 1:
|
||||||
|
conflict_count += 1
|
||||||
|
skipped_count += 1
|
||||||
|
duplicate_ids = [row['id'] for row in existing]
|
||||||
|
conflicts.append({
|
||||||
|
"economic_customer_number": customer_number,
|
||||||
|
"name": name,
|
||||||
|
"duplicate_customer_ids": duplicate_ids,
|
||||||
|
"reason": "Flere lokale kunder har samme e-conomic kundenummer"
|
||||||
|
})
|
||||||
|
logger.error(
|
||||||
|
"❌ Konflikt: e-conomic #%s matcher %s lokale kunder (ids: %s) - springer over",
|
||||||
|
customer_number,
|
||||||
|
len(existing),
|
||||||
|
", ".join(str(i) for i in duplicate_ids)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
target_customer_id = existing[0]['id']
|
||||||
|
would_update.append({
|
||||||
|
"customer_id": target_customer_id,
|
||||||
|
"customer_name": existing[0].get('name'),
|
||||||
|
"economic_customer_number": customer_number,
|
||||||
|
"new_values": {
|
||||||
|
"email_domain": email_domain,
|
||||||
|
"address": address,
|
||||||
|
"city": city,
|
||||||
|
"postal_code": zip_code,
|
||||||
|
"country": country,
|
||||||
|
"website": website,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if apply_changes:
|
||||||
|
update_query = """
|
||||||
|
UPDATE customers SET
|
||||||
|
economic_customer_number = %s,
|
||||||
|
email_domain = %s,
|
||||||
|
address = %s,
|
||||||
|
city = %s,
|
||||||
|
postal_code = %s,
|
||||||
|
country = %s,
|
||||||
|
website = %s,
|
||||||
|
last_synced_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
execute_query(update_query, (
|
||||||
|
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id
|
||||||
|
))
|
||||||
|
logger.info(
|
||||||
|
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
||||||
|
target_customer_id,
|
||||||
|
name,
|
||||||
|
customer_number,
|
||||||
|
cvr or 'ingen'
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
would_create.append({
|
||||||
|
"name": name,
|
||||||
|
"economic_customer_number": customer_number,
|
||||||
|
"cvr_number": cvr,
|
||||||
|
"email_domain": email_domain,
|
||||||
|
"address": address,
|
||||||
|
"city": city,
|
||||||
|
"postal_code": zip_code,
|
||||||
|
"country": country,
|
||||||
|
"website": website,
|
||||||
|
})
|
||||||
|
|
||||||
|
if apply_changes:
|
||||||
|
insert_query = """
|
||||||
|
INSERT INTO customers
|
||||||
|
(name, economic_customer_number, cvr_number, email_domain,
|
||||||
|
address, city, postal_code, country, website, last_synced_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
result = execute_query(insert_query, (
|
||||||
|
name, customer_number, cvr, email_domain, address, city, zip_code, country, website
|
||||||
|
))
|
||||||
|
if result:
|
||||||
|
logger.info(
|
||||||
|
"✨ Oprettet lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
||||||
|
result[0]['id'],
|
||||||
|
name,
|
||||||
|
customer_number,
|
||||||
|
cvr or 'ingen'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
skipped.append({
|
||||||
|
"economic_customer_number": customer_number,
|
||||||
|
"name": name,
|
||||||
|
"reason": "Insert RETURNING gav tomt resultat"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ e-conomic %s fuldført: %s oprettet, %s opdateret, %s konflikter, %s sprunget over af %s totalt",
|
||||||
|
"sync" if apply_changes else "dry-run",
|
||||||
|
created_count,
|
||||||
|
updated_count,
|
||||||
|
conflict_count,
|
||||||
|
skipped_count,
|
||||||
|
len(economic_customers)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"mode": "apply" if apply_changes else "dry_run",
|
||||||
|
"created": created_count,
|
||||||
|
"updated": updated_count,
|
||||||
|
"conflicts": conflict_count,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"total_processed": len(economic_customers),
|
||||||
|
"contacts": {
|
||||||
|
"supported": False,
|
||||||
|
"message": "Kontakt-sync fra e-conomic er ikke implementeret endnu i denne pipeline. Dry-run dækker firmaer."
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"would_create": would_create[:25],
|
||||||
|
"would_update": would_update[:25],
|
||||||
|
"conflicts": conflicts[:25],
|
||||||
|
"skipped": skipped[:25],
|
||||||
|
"truncated": {
|
||||||
|
"would_create_total": len(would_create),
|
||||||
|
"would_update_total": len(would_update),
|
||||||
|
"conflicts_total": len(conflicts),
|
||||||
|
"skipped_total": len(skipped),
|
||||||
|
"limit_per_list": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/vtiger")
|
@router.post("/sync/vtiger")
|
||||||
async def sync_from_vtiger(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
async def sync_from_vtiger(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@ -455,158 +716,34 @@ async def sync_from_economic(current_user: dict = Depends(sync_admin_access)) ->
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("🔄 Starting e-conomic customer sync (PRIMARY SOURCE)...")
|
logger.info("🔄 Starting e-conomic customer sync (PRIMARY SOURCE)...")
|
||||||
|
result = await _economic_sync_apply_or_preview(apply_changes=True)
|
||||||
from app.services.economic_service import EconomicService
|
return result
|
||||||
economic = EconomicService()
|
except HTTPException:
|
||||||
|
raise
|
||||||
# Get all customers from e-conomic (max 1000 per page)
|
|
||||||
all_customers = []
|
|
||||||
page = 0
|
|
||||||
while True:
|
|
||||||
customers = await economic.get_customers(page=page, page_size=1000)
|
|
||||||
if not customers:
|
|
||||||
break
|
|
||||||
all_customers.extend(customers)
|
|
||||||
page += 1
|
|
||||||
if len(customers) < 1000: # Last page
|
|
||||||
break
|
|
||||||
|
|
||||||
economic_customers = all_customers
|
|
||||||
logger.info(f"📥 Fetched {len(economic_customers)} customers from e-conomic ({page} pages)")
|
|
||||||
|
|
||||||
created_count = 0
|
|
||||||
updated_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
conflict_count = 0
|
|
||||||
|
|
||||||
for eco_customer in economic_customers:
|
|
||||||
customer_number_raw = eco_customer.get('customerNumber')
|
|
||||||
customer_number = str(customer_number_raw).strip() if customer_number_raw is not None else None
|
|
||||||
cvr = eco_customer.get('corporateIdentificationNumber')
|
|
||||||
name = eco_customer.get('name', '').strip()
|
|
||||||
address = eco_customer.get('address', '')
|
|
||||||
city = eco_customer.get('city', '')
|
|
||||||
zip_code = eco_customer.get('zip', '')
|
|
||||||
country = eco_customer.get('country', 'DK')
|
|
||||||
email = eco_customer.get('email', '')
|
|
||||||
website = eco_customer.get('website', '')
|
|
||||||
|
|
||||||
if not customer_number or not name:
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Clean CVR
|
|
||||||
if cvr:
|
|
||||||
cvr = re.sub(r'\D', '', str(cvr))[:8]
|
|
||||||
if len(cvr) != 8:
|
|
||||||
cvr = None
|
|
||||||
|
|
||||||
# Clean country code (max 2 chars for ISO codes)
|
|
||||||
if country:
|
|
||||||
country = str(country).strip().upper()[:2]
|
|
||||||
if not country:
|
|
||||||
country = 'DK'
|
|
||||||
else:
|
|
||||||
country = 'DK'
|
|
||||||
|
|
||||||
# Extract email domain
|
|
||||||
email_domain = email.split('@')[-1] if '@' in email else None
|
|
||||||
|
|
||||||
# Strict matching: ONLY match by economic_customer_number
|
|
||||||
existing = execute_query(
|
|
||||||
"SELECT id, name FROM customers WHERE economic_customer_number = %s ORDER BY id",
|
|
||||||
(customer_number,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Conflict handling: duplicate local rows for same e-conomic number
|
|
||||||
if len(existing) > 1:
|
|
||||||
conflict_count += 1
|
|
||||||
skipped_count += 1
|
|
||||||
duplicate_ids = ", ".join(str(row['id']) for row in existing)
|
|
||||||
logger.error(
|
|
||||||
"❌ Konflikt: e-conomic #%s matcher %s lokale kunder (ids: %s) - springer over",
|
|
||||||
customer_number,
|
|
||||||
len(existing),
|
|
||||||
duplicate_ids
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
target_customer_id = existing[0]['id']
|
|
||||||
# Update existing customer - ONLY update fields e-conomic owns
|
|
||||||
# E-conomic does NOT overwrite: name, cvr_number (set once only)
|
|
||||||
update_query = """
|
|
||||||
UPDATE customers SET
|
|
||||||
economic_customer_number = %s,
|
|
||||||
email_domain = %s,
|
|
||||||
address = %s,
|
|
||||||
city = %s,
|
|
||||||
postal_code = %s,
|
|
||||||
country = %s,
|
|
||||||
website = %s,
|
|
||||||
last_synced_at = NOW()
|
|
||||||
WHERE id = %s
|
|
||||||
"""
|
|
||||||
execute_query(update_query, (
|
|
||||||
customer_number, email_domain, address, city, zip_code, country, website, target_customer_id
|
|
||||||
))
|
|
||||||
updated_count += 1
|
|
||||||
logger.info(
|
|
||||||
"✏️ Opdateret lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
|
||||||
target_customer_id,
|
|
||||||
name,
|
|
||||||
customer_number,
|
|
||||||
cvr or 'ingen'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Create new customer from e-conomic
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO customers
|
|
||||||
(name, economic_customer_number, cvr_number, email_domain,
|
|
||||||
address, city, postal_code, country, website, last_synced_at)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
RETURNING id
|
|
||||||
"""
|
|
||||||
result = execute_query(insert_query, (
|
|
||||||
name, customer_number, cvr, email_domain, address, city, zip_code, country, website
|
|
||||||
))
|
|
||||||
|
|
||||||
if result:
|
|
||||||
new_customer_id = result[0]['id']
|
|
||||||
created_count += 1
|
|
||||||
logger.info(
|
|
||||||
"✨ Oprettet lokal kunde id=%s: %s (e-conomic #%s, CVR: %s)",
|
|
||||||
new_customer_id,
|
|
||||||
name,
|
|
||||||
customer_number,
|
|
||||||
cvr or 'ingen'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
skipped_count += 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"✅ e-conomic sync fuldført: %s oprettet, %s opdateret, %s konflikter, %s sprunget over af %s totalt",
|
|
||||||
created_count,
|
|
||||||
updated_count,
|
|
||||||
conflict_count,
|
|
||||||
skipped_count,
|
|
||||||
len(economic_customers)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"created": created_count,
|
|
||||||
"updated": updated_count,
|
|
||||||
"conflicts": conflict_count,
|
|
||||||
"skipped": skipped_count,
|
|
||||||
"total_processed": len(economic_customers)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ e-conomic sync error: {e}")
|
logger.error(f"❌ e-conomic sync error: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/economic/dry-run")
|
||||||
|
@router.post("/sync/economic/dryrun")
|
||||||
|
@router.post("/sync/economic/preview")
|
||||||
|
async def sync_from_economic_dry_run(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Dry-run for e-conomic customer sync.
|
||||||
|
No database writes are performed; returns a preview of intended actions.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("🧪 Starting e-conomic customer sync DRY-RUN preview...")
|
||||||
|
result = await _economic_sync_apply_or_preview(apply_changes=False)
|
||||||
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ e-conomic dry-run sync error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync/cvr-to-economic")
|
@router.post("/sync/cvr-to-economic")
|
||||||
async def sync_cvr_to_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
async def sync_cvr_to_economic(current_user: dict = Depends(sync_admin_access)) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
2
main.py
2
main.py
@ -132,6 +132,7 @@ from app.modules.calendar.frontend import views as calendar_views
|
|||||||
from app.modules.orders.backend import router as orders_api
|
from app.modules.orders.backend import router as orders_api
|
||||||
from app.modules.orders.frontend import views as orders_views
|
from app.modules.orders.frontend import views as orders_views
|
||||||
from app.modules.fedex.backend import router as fedex_api
|
from app.modules.fedex.backend import router as fedex_api
|
||||||
|
from app.modules.fedex.frontend import views as fedex_views
|
||||||
from app.modules.manual.backend import router as manual_api
|
from app.modules.manual.backend import router as manual_api
|
||||||
from app.modules.manual.frontend import views as manual_views
|
from app.modules.manual.frontend import views as manual_views
|
||||||
from app.modules.bottom_bar.backend import router as bottom_bar_api
|
from app.modules.bottom_bar.backend import router as bottom_bar_api
|
||||||
@ -488,6 +489,7 @@ app.include_router(devportal_views.router, tags=["Frontend"])
|
|||||||
app.include_router(telefoni_views.router, tags=["Frontend"])
|
app.include_router(telefoni_views.router, tags=["Frontend"])
|
||||||
app.include_router(calendar_views.router, tags=["Frontend"])
|
app.include_router(calendar_views.router, tags=["Frontend"])
|
||||||
app.include_router(orders_views.router, tags=["Frontend"])
|
app.include_router(orders_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(fedex_views.router, tags=["Frontend"])
|
||||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||||
app.include_router(manual_views.router, tags=["Frontend"])
|
app.include_router(manual_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user