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 {
position: relative;
min-width: 280px;
max-width: 460px;
width: min(46vw, 460px);
max-width: 520px;
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 {
width: 100%;
border: 0;
background: transparent;
padding-left: 2.25rem;
padding-right: 2.4rem;
font-weight: 500;
}
.search-wrap .header-search::placeholder {
color: var(--text-secondary);
opacity: 0.95;
}
.search-clear {
@ -68,6 +113,44 @@
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 {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 14px;
@ -117,6 +200,41 @@
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 {
font-weight: 700;
color: var(--text-primary);
@ -138,17 +256,63 @@
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.18rem;
margin-top: 0.08rem;
flex-wrap: wrap;
}
.contact-quick-actions .btn {
border-radius: 999px;
padding: 0.08rem 0.52rem;
font-size: 0.72rem;
padding: 0.04rem 0.45rem;
font-size: 0.69rem;
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 {
display: inline-flex;
align-items: center;
@ -354,10 +518,14 @@
</div>
<div class="toolbar-search-slot">
<div class="search-wrap">
<input type="search" id="searchInput" class="header-search" placeholder="Søg navn, email, telefon eller firma..." autocomplete="off" spellcheck="false">
<button type="button" id="searchClearBtn" class="search-clear d-none" aria-label="Ryd søgning" title="Ryd søgning">
<i class="bi bi-x-lg"></i>
</button>
<div class="search-label"><i class="bi bi-search"></i>Hurtig søgning</div>
<div class="search-input-shell">
<i class="bi bi-search search-icon"></i>
<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>
<button class="btn btn-primary" onclick="showCreateContactModal()">
@ -378,21 +546,67 @@
</div>
<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">
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Kontakt Info</th>
<th>Titel</th>
<th>Firmaer</th>
<th>Status</th>
<th>
<button type="button" class="sort-btn" data-sort-key="name" onclick="setSort('name')">
Navn <i class="bi bi-arrow-down-up sort-indicator"></i>
</button>
</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>
</tr>
</thead>
<tbody id="contactsTableBody">
<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">
<span class="visually-hidden">Loading...</span>
</div>
@ -602,9 +816,23 @@ let currentRequestController = null;
let lastLoadedQueryKey = '';
let availableCompanies = [];
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
document.addEventListener('DOMContentLoaded', () => {
loadTablePreferences();
applyColumnVisibility();
updateSortIndicators();
loadContacts();
loadCompaniesForSelect();
@ -663,6 +891,15 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('companySearchInput')?.addEventListener('input', (e) => {
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) {
@ -680,7 +917,7 @@ function setFilter(filter) {
async function loadContacts() {
const tbody = document.getElementById('contactsTableBody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
if (currentRequestController) {
currentRequestController.abort();
@ -714,7 +951,8 @@ async function loadContacts() {
const data = await response.json();
totalContacts = data.total;
displayContacts(data.contacts);
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
displayContacts(currentContactsData);
updatePagination(data.total);
} catch (error) {
@ -722,7 +960,7 @@ async function loadContacts() {
return;
}
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 {
currentRequestController = null;
}
@ -734,13 +972,16 @@ function toggleClearButton(value) {
function displayContacts(contacts) {
const tbody = document.getElementById('contactsTableBody');
const sortedContacts = getSortedContacts(contacts);
if (!contacts || contacts.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-5 text-muted">Ingen kontakter fundet</td></tr>';
if (!sortedContacts || sortedContacts.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-muted">Ingen kontakter fundet</td></tr>';
applyColumnVisibility();
updateSortIndicators();
return;
}
tbody.innerHTML = contacts.map(contact => {
tbody.innerHTML = sortedContacts.map(contact => {
const initials = getInitials(contact.first_name, contact.last_name);
const statusBadge = contact.is_active
? '<span class="status-pill active">Aktiv</span>'
@ -752,23 +993,16 @@ function displayContacts(contacts) {
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
: '-';
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const mobileLine = contact.mobile
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.mobile)}
<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.mobile)}')">Ring op</button>
<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 preferredPhone = contact.mobile || contact.phone || '';
const hasEmail = !!contact.email;
const hasPreferredPhone = !!preferredPhone;
const safeName = escapeHtml(`${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '-');
const safeDepartment = escapeHtml(contact.department || '-');
const safeEmail = escapeHtml(contact.email || '-');
const safeTitle = escapeHtml(contact.title || '-');
const safePhone = escapeHtml(preferredPhone || '-');
const companiesTitle = escapeHtml(companyNames.join(', '));
const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
return `
<tr onclick="viewContact(${contact.id})">
@ -782,17 +1016,38 @@ function displayContacts(contacts) {
</div>
</td>
<td>
<div class="contact-info-main">${safeEmail}</div>
<div class="contact-quick-actions">${smsLine}</div>
<div class="contact-data-stack">
<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 class="text-muted">${safeTitle}</td>
<td>
<td class="text-muted col-title">${safeTitle}</td>
<td class="col-companies">
<span class="company-count-chip" title="${companiesTitle}">
<i class="bi bi-building"></i>${companyCount}
</span>
${companyDisplay !== '-' ? '<div class="small text-muted mt-1">' + escapeHtml(companyDisplay) + '</div>' : ''}
</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">
<div class="btn-group">
<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>
`;
}).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) {
@ -1114,6 +1488,17 @@ function getInitials(firstName, lastName) {
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) {
const div = document.createElement('div');
div.textContent = text;

View File

@ -326,6 +326,217 @@ class MissionService:
result.append(item)
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
def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]:
if not MissionService._table_exists("email_messages"):
@ -392,6 +603,10 @@ class MissionService:
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),
"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), []),
"environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []),
"config": {

View File

@ -78,9 +78,19 @@
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 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.6rem;
}
@ -94,6 +104,7 @@
font-weight: 700;
letter-spacing: 0.01em;
transition: 0.15s ease;
touch-action: manipulation;
}
.mc-nav-btn.active {
@ -126,6 +137,7 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
touch-action: manipulation;
}
.mc-chip.active {
@ -389,6 +401,7 @@
font-size: 0.84rem;
font-weight: 700;
min-width: 54px;
touch-action: manipulation;
}
.mc-duration-btn.active {
@ -493,6 +506,233 @@
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 {
font-weight: 700;
font-size: 0.9rem;
@ -547,6 +787,10 @@
.mc-row-head {
grid-template-columns: 1.7fr 1fr 1fr 1fr;
}
.mc-day-agents {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
@ -564,6 +808,10 @@
min-height: min(70vh, 720px);
}
.mc-day-assign-row {
grid-template-columns: 1fr;
}
.mc-email-row {
grid-template-columns: 1fr;
}
@ -599,16 +847,13 @@
<div class="mc-nav" id="missionNav">
<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="calls">Opkald</button>
<button class="mc-nav-btn" type="button" data-view="camera">Kamera</button>
</div>
</section>
<section class="mc-card">
<div class="mc-filter-row" id="caseFilterChips"></div>
</section>
<section>
<div id="view-overview" class="mc-view active">
<div class="mc-view-grid">
@ -658,6 +903,26 @@
</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 class="mc-view-grid">
<div class="mc-card">
@ -695,10 +960,6 @@
</div>
</section>
<section class="mc-card">
<h5 class="mb-2">Live aktivitetsfeed</h5>
<div id="liveFeed" class="mc-feed"></div>
</section>
</div>
<script>
@ -730,6 +991,7 @@
idleTimeoutMs: 10000,
currentView: 'overview',
caseFilter: 'all',
dayTab: 'new_cases',
preSpotlightView: null,
cameraSpotlightTimer: null,
spotlightTargetId: null,
@ -751,6 +1013,10 @@
activeAlerts: [],
liveFeed: [],
importantCases: [],
dayUnassignedCases: [],
dayAgentWorkloads: [],
assignmentUsers: [],
assignmentGroups: [],
recentEmails: [],
environmentReadings: [],
cameraMotion: null,
@ -789,6 +1055,20 @@
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) {
const id = Number(emailId || 0);
if (!Number.isFinite(id) || id <= 0) return '/emails';
@ -1183,6 +1463,190 @@
`).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() {
const container = document.getElementById('environmentReadings');
if (!container) return;
@ -1286,6 +1750,9 @@
renderActiveCalls();
renderDeadlines();
renderImportantCases();
renderDayTabs();
renderDayUnassignedCases();
renderDayAgentWorkloads();
renderRecentEmails();
renderEnvironmentReadings();
renderFeed();
@ -1304,6 +1771,10 @@
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.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.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) => {
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;
}
.links-summary .summary-card {
box-shadow: 0 4px 14px rgba(2, 32, 71, 0.06);
}
.summary-card {
border-radius: 12px;
padding: 0.62rem 0.7rem;
@ -66,6 +70,25 @@
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 {
margin-bottom: 1.2rem;
}
@ -296,6 +319,87 @@
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 {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.86rem;
@ -310,6 +414,11 @@
.target-text {
max-width: 170px;
}
.links-results-meta {
flex-direction: column;
align-items: flex-start;
}
}
</style>
{% endblock %}
@ -353,7 +462,7 @@
<div class="card border-0 shadow-sm mb-2 links-filter-card">
<div class="card-body">
<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>
<div class="input-group search-shell">
<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>
</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>
<select id="statusFilter" class="form-select">
<option value="all">Alle</option>
@ -370,10 +479,28 @@
<option value="unknown">Unknown</option>
</select>
</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 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 id="linksColumns">
<div class="text-muted small">Henter links...</div>
@ -531,6 +658,7 @@
links: [],
statuses: new Map(),
categories: [],
viewMode: localStorage.getItem('linksViewMode') || 'cards',
};
let linkModal;
@ -634,6 +762,7 @@
function applyFilters() {
const q = (document.getElementById('searchInput').value || '').toLowerCase().trim();
const statusFilter = document.getElementById('statusFilter').value;
const categoryFilter = document.getElementById('categoryFilter').value;
const rows = state.links.filter((link) => {
const statusRow = state.statuses.get(link.id);
@ -643,15 +772,35 @@
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;
const categoryNames = resolveCategoryNamesForLink(link).join(' ');
const hay = `${link.name || ''} ${link.url || ''} ${link.host || ''} ${link.environment || ''} ${link.type || ''} ${categoryNames}`.toLowerCase();
return hay.includes(q);
});
renderResultsMeta(rows.length);
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) {
const ids = Array.isArray(link.category_ids) ? link.category_ids : [];
if (!ids.length) {
@ -752,10 +901,115 @@
}).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) {
if (state.viewMode === 'list') {
renderList(rows);
return;
}
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() {
const counts = { ok: 0, down: 0, unknown: 0 };
state.links.forEach((link) => {
@ -799,6 +1053,7 @@
}
const select = document.getElementById('fCategoryIds');
select.innerHTML = state.categories.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join('');
populateCategoryFilter();
if (state.links.length) {
applyFilters();
@ -990,11 +1245,14 @@
document.getElementById('searchInput').addEventListener('input', applyFilters);
document.getElementById('statusFilter').addEventListener('change', applyFilters);
document.getElementById('categoryFilter').addEventListener('change', applyFilters);
document.getElementById('clearSearchBtn').addEventListener('click', () => {
document.getElementById('searchInput').value = '';
applyFilters();
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('runHealthBtn').addEventListener('click', runHealthCheck);
document.getElementById('createBtn').addEventListener('click', () => {
@ -1008,8 +1266,11 @@
if (customerIdFilter) {
document.getElementById('scopeHint').textContent = `Filter: Kunde #${customerIdFilter}`;
document.getElementById('resultsScope').textContent = `Scope: Kunde #${customerIdFilter}`;
}
setViewMode(state.viewMode);
Promise.all([loadCategories(), loadData()]);
</script>
{% endblock %}

View File

@ -1,6 +1,7 @@
import json
import logging
import base64
import ipaddress
import re
from datetime import datetime
from typing import Optional
@ -96,16 +97,31 @@ def _get_client_ip(request: Request) -> str:
if cf_ip:
return cf_ip.strip()
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
return x_real_ip.strip()
true_client_ip = request.headers.get("true-client-ip")
if true_client_ip:
return true_client_ip.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
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 ""
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:
env_secret = (getattr(settings, "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")
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"
if not enabled:
raise HTTPException(status_code=400, detail="Click-to-call is disabled")

View File

@ -67,6 +67,69 @@
</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-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
@ -124,6 +187,20 @@ const linkSagState = {
searchToken: 0,
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() {
if (telefoniCurrentUserId !== null) return telefoniCurrentUserId;
@ -147,6 +224,385 @@ function getLinkSagModalInstance() {
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) {
const selected = document.getElementById('linkSagSelected');
const confirmBtn = document.getElementById('linkSagConfirm');
@ -408,8 +864,15 @@ async function loadCalls() {
: '-';
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>` : ''}`
: '<span class="text-muted">-</span>';
? `<div class="d-flex align-items-center gap-2 flex-wrap">
<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 createQs = new URLSearchParams();
@ -500,6 +963,7 @@ async function unlinkCase(callId) {
}
document.addEventListener('DOMContentLoaded', async () => {
initLinkContactModalEvents();
initLinkSagModalEvents();
const userFilter = document.getElementById('filterUser');
const fromFilter = document.getElementById('filterFrom');

View File

@ -129,13 +129,14 @@ class EconomicService:
# ========== 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
Args:
page: Page number (0-indexed)
page_size: Number of records per page
strict: Raise exception on non-200 responses instead of returning []
Returns:
List of customer records with customerNumber, corporateIdentificationNumber, name
@ -155,9 +156,25 @@ class EconomicService:
else:
error = await response.text()
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 []
except Exception as e:
logger.error(f"❌ Error fetching customers from e-conomic: {e}")
if strict:
raise
return []
async def search_customer_by_cvr(self, cvr: str) -> Optional[Dict]:

View File

@ -960,9 +960,12 @@
</div>
</div>
<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
</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>
<i class="bi bi-pause-circle me-2"></i>CVR→e-conomic midlertidigt deaktiveret
</button>
@ -1059,6 +1062,61 @@
</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>
@ -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;
}
@ -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() {
showNotification('CVR→e-conomic sync er midlertidigt deaktiveret.', 'info');
return;

View File

@ -17,6 +17,9 @@
--text-secondary: #6c757d;
--accent: #0f4c75;
--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;
--bottom-bar-height: 50px;
--bottom-bar-expanded-height: 50vh;
@ -32,6 +35,9 @@
--text-secondary: #adb5bd;
--accent: #3d8bfd; /* Lighter blue for dark mode */
--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 {
@ -566,15 +572,66 @@
}
.card {
border: none;
border: 2px solid var(--frame-border-strong);
border-left: 4px solid var(--accent);
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
transition: transform 0.2s, background-color 0.3s;
background: var(--bg-card);
box-shadow: var(--frame-shadow);
transition: transform 0.2s, background-color 0.3s, border-color 0.2s, box-shadow 0.2s;
background: linear-gradient(165deg, color-mix(in srgb, var(--accent) 6%, var(--bg-card)) 0%, var(--bg-card) 100%);
overflow: hidden;
}
.card:hover {
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 {
@ -702,6 +759,10 @@
</style>
{% block extra_css %}{% endblock %}
</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>
<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/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="/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="/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>
@ -1207,6 +1269,59 @@ window.addEventListener('unhandledrejection', function(event) {
<script src="/static/js/bottom-bar.js?v=2.23"></script>
<script>
// 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 htmlElement = document.documentElement;
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.auth_dependencies import require_any_permission
from app.services.vtiger_service import get_vtiger_service
import aiohttp
import re
logger = logging.getLogger(__name__)
@ -54,6 +55,266 @@ def normalize_name(name: str) -> str:
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")
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:
logger.info("🔄 Starting e-conomic customer sync (PRIMARY SOURCE)...")
from app.services.economic_service import EconomicService
economic = EconomicService()
# 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)
}
result = await _economic_sync_apply_or_preview(apply_changes=True)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ e-conomic sync error: {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")
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.frontend import views as orders_views
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.frontend import views as manual_views
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(calendar_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(manual_views.router, tags=["Frontend"])