Release: mission day workflow, telefoni contact modal, fedex support overview, and economic sync dry-run

This commit is contained in:
Christian 2026-05-02 11:02:29 +02:00
parent f2c8af4680
commit 5ee962fdb3
14 changed files with 2880 additions and 208 deletions

View File

@ -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;

View File

@ -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": {

View File

@ -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 });
}); });

View File

View 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 %}

View 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},
)

View File

@ -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 %}

View File

@ -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")

View File

@ -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');

View File

@ -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]:

View File

@ -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;

View File

@ -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');

View File

@ -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]:
""" """

View File

@ -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"])