+ | └ |
- #{{ related_sag.id }}
+ #{{ related_sag.id }}
{% if (related_sag.unread_email_count or 0) > 0 %}
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
@@ -597,22 +884,20 @@
|
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
+
+ {% set related_status_raw = related_sag.status if related_sag.status else 'åben' %}
+ {% set related_status_class = related_status_raw|lower|replace(' ', '-') %}
+ {{ related_status_raw }}
+ |
- {% if related_sag.ansvarlig_navn %}
- {% set owner_name = related_sag.ansvarlig_navn.strip() %}
- {% set owner_parts = owner_name.split() %}
-
- {{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
-
- {{ owner_name }}
+ {{ initials_bubble(related_sag.ansvarlig_navn) }}
- {% else %}
- -
- {% endif %}
|
- {{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
+
+ {{ initials_bubble(related_sag.assigned_group_name, true) }}
+
|
{% if related_sag.next_todo_title %}
@@ -741,30 +1026,41 @@
const allRows = document.querySelectorAll('.tree-row');
let currentSearch = '';
let currentFilter = 'all';
- let currentType = 'all';
+ let currentTypes = new Set();
+ let currentAssignees = new Set();
+ let currentGroups = new Set();
+ const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
const assigneeFilter = document.getElementById('assigneeFilter');
const groupFilter = document.getElementById('groupFilter');
- const assignmentFilterForm = document.getElementById('assignmentFilterForm');
-
- if (assigneeFilter && assignmentFilterForm) {
- assigneeFilter.addEventListener('change', () => assignmentFilterForm.submit());
- }
- if (groupFilter && assignmentFilterForm) {
- groupFilter.addEventListener('change', () => assignmentFilterForm.submit());
- }
+ const assigneeFilterList = document.getElementById('assigneeFilterList');
+ const groupFilterList = document.getElementById('groupFilterList');
+ const assigneeDropdownLabel = document.getElementById('assigneeDropdownLabel');
+ const groupDropdownLabel = document.getElementById('groupDropdownLabel');
+ const clearAssigneeFilterBtn = document.getElementById('clearAssigneeFilterBtn');
+ const clearGroupFilterBtn = document.getElementById('clearGroupFilterBtn');
function applyFilters() {
const search = currentSearch;
allRows.forEach(row => {
const text = row.textContent.toLowerCase();
- const status = row.dataset.status;
+ const status = String(row.dataset.status || '').toLowerCase();
const type = row.dataset.type || 'ticket';
+ const assigneeRaw = String(row.dataset.assigneeId || '').trim();
+ const groupRaw = String(row.dataset.groupId || '').trim();
+ const assigneeId = assigneeRaw || '__UNASSIGNED__';
+ const groupId = groupRaw;
const matchesSearch = text.includes(search);
- const matchesFilter = currentFilter === 'all' || status === currentFilter;
- const matchesType = currentType === 'all' || type === currentType;
- const visible = matchesSearch && matchesFilter && matchesType;
+ const isClosed = closedStatuses.has(status);
+ const matchesFilter = currentFilter === 'all'
+ || (currentFilter === 'åben' && !isClosed)
+ || (currentFilter === 'lukket' && isClosed)
+ || status === currentFilter;
+ const matchesType = currentTypes.size === 0 || currentTypes.has(type);
+ const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
+ const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
+ const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup;
row.style.display = visible ? '' : 'none';
@@ -773,18 +1069,130 @@
const children = document.querySelectorAll(`tr[data-parent="${sagId}"]`);
children.forEach(child => {
const childText = child.textContent.toLowerCase();
- const childStatus = child.dataset.status;
+ const childStatus = String(child.dataset.status || '').toLowerCase();
const childType = child.dataset.type || 'ticket';
+ const childAssigneeRaw = String(child.dataset.assigneeId || '').trim();
+ const childGroupRaw = String(child.dataset.groupId || '').trim();
+ const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
+ const childGroupId = childGroupRaw;
const childMatchesSearch = childText.includes(search);
- const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter;
- const childMatchesType = currentType === 'all' || childType === currentType;
- const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType;
+ const childIsClosed = closedStatuses.has(childStatus);
+ const childMatchesFilter = currentFilter === 'all'
+ || (currentFilter === 'åben' && !childIsClosed)
+ || (currentFilter === 'lukket' && childIsClosed)
+ || childStatus === currentFilter;
+ const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
+ const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
+ const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
+ const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup;
child.style.display = childVisible ? '' : 'none';
});
}
});
}
+ function renderMiniFilterOptions(selectEl, listEl, selectedSet, labelEl, defaultLabel) {
+ if (!selectEl || !listEl) return;
+ const options = Array.from(selectEl.options || []).filter((opt) => String(opt.value || '').trim() !== '');
+ if (options.length === 0) {
+ listEl.innerHTML = 'Ingen muligheder';
+ if (labelEl) labelEl.textContent = defaultLabel;
+ return;
+ }
+
+ listEl.innerHTML = options.map((opt) => {
+ const value = String(opt.value || '').trim();
+ const isChecked = selectedSet.has(value);
+ const safeValue = value.replace(/"/g, '"');
+ const safeLabel = String(opt.textContent || value).replace(/"/g, '"');
+ const safeId = `${selectEl.id}-opt-${value.replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
+ return `
+
+ `;
+ }).join('');
+
+ if (labelEl) {
+ labelEl.textContent = selectedSet.size === 0 ? defaultLabel : `${selectedSet.size} valgt`;
+ }
+ }
+
+ function syncMiniSelect(selectEl, selectedSet) {
+ if (!selectEl) return;
+ Array.from(selectEl.options || []).forEach((opt) => {
+ const value = String(opt.value || '').trim();
+ opt.selected = value !== '' && selectedSet.has(value);
+ });
+ }
+
+ if (assigneeFilterList && assigneeFilter) {
+ assigneeFilterList.addEventListener('change', function(event) {
+ const checkbox = event.target.closest('input[type="checkbox"][data-value]');
+ if (!checkbox) return;
+ const value = String(checkbox.dataset.value || '').trim();
+ if (!value) return;
+ if (checkbox.checked) currentAssignees.add(value);
+ else currentAssignees.delete(value);
+ syncMiniSelect(assigneeFilter, currentAssignees);
+ renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
+ applyFilters();
+ });
+ }
+
+ if (groupFilterList && groupFilter) {
+ groupFilterList.addEventListener('change', function(event) {
+ const checkbox = event.target.closest('input[type="checkbox"][data-value]');
+ if (!checkbox) return;
+ const value = String(checkbox.dataset.value || '').trim();
+ if (!value) return;
+ if (checkbox.checked) currentGroups.add(value);
+ else currentGroups.delete(value);
+ syncMiniSelect(groupFilter, currentGroups);
+ renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
+ applyFilters();
+ });
+ }
+
+ if (clearAssigneeFilterBtn) {
+ clearAssigneeFilterBtn.addEventListener('click', function() {
+ currentAssignees = new Set();
+ syncMiniSelect(assigneeFilter, currentAssignees);
+ renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
+ applyFilters();
+ });
+ }
+
+ if (clearGroupFilterBtn) {
+ clearGroupFilterBtn.addEventListener('click', function() {
+ currentGroups = new Set();
+ syncMiniSelect(groupFilter, currentGroups);
+ renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
+ applyFilters();
+ });
+ }
+
+ function initMiniFilterSelections() {
+ if (assigneeFilter) {
+ currentAssignees = new Set(
+ Array.from(assigneeFilter.selectedOptions || [])
+ .map((opt) => String(opt.value || '').trim())
+ .filter((value) => value)
+ );
+ renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
+ }
+
+ if (groupFilter) {
+ currentGroups = new Set(
+ Array.from(groupFilter.selectedOptions || [])
+ .map((opt) => String(opt.value || '').trim())
+ .filter((value) => value)
+ );
+ renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
+ }
+ }
+
if (searchInput) {
searchInput.addEventListener('input', function(e) {
currentSearch = e.target.value.toLowerCase();
@@ -807,9 +1215,83 @@
});
const typeFilter = document.getElementById('typeFilter');
- if (typeFilter) {
- typeFilter.addEventListener('change', function() {
- currentType = this.value || 'all';
+ const typeFilterCheckboxList = document.getElementById('typeFilterCheckboxList');
+ const typeFilterDropdownLabel = document.getElementById('typeFilterDropdownLabel');
+ const typeFilterSelection = document.getElementById('typeFilterSelection');
+ const clearTypeFilterBtn = document.getElementById('clearTypeFilterBtn');
+ const saveTypeFilterDefaultBtn = document.getElementById('saveTypeFilterDefaultBtn');
+
+ function getSelectedTypesFromUi() {
+ return Array.from(currentTypes);
+ }
+
+ function renderTypeFilterOptions() {
+ if (!typeFilterCheckboxList || !typeFilter) return;
+ const options = Array.from(typeFilter.options || []);
+ if (options.length === 0) {
+ typeFilterCheckboxList.innerHTML = 'Ingen typer fundet';
+ if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgaengelige';
+ if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Ingen typer';
+ return;
+ }
+
+ typeFilterCheckboxList.innerHTML = options.map((opt) => {
+ const value = String(opt.value || '').trim();
+ const key = value.toLowerCase();
+ const isActive = currentTypes.has(key);
+ const safeValue = value.replace(/"/g, '"');
+ const safeId = `type-filter-opt-${key.replace(/[^a-z0-9_-]+/g, '-')}`;
+ return `
+
+ `;
+ }).join('');
+
+ if (typeFilterSelection) {
+ const selectedLabels = options
+ .filter((opt) => currentTypes.has(String(opt.value || '').trim().toLowerCase()))
+ .map((opt) => String(opt.value || '').trim())
+ .filter(Boolean);
+ if (selectedLabels.length === 0) {
+ typeFilterSelection.textContent = 'Ingen typer valgt';
+ if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Vælg typer';
+ } else {
+ typeFilterSelection.textContent = `Valgt: ${selectedLabels.join(', ')}`;
+ if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = `${selectedLabels.length} valgt`;
+ }
+ }
+ }
+
+ function applySelectedTypesToUi() {
+ if (!typeFilter) return;
+ Array.from(typeFilter.options || []).forEach((opt) => {
+ opt.selected = currentTypes.has(String(opt.value || '').trim().toLowerCase());
+ });
+ renderTypeFilterOptions();
+ }
+
+ if (typeFilterCheckboxList) {
+ typeFilterCheckboxList.addEventListener('change', function(event) {
+ const checkbox = event.target.closest('input[type="checkbox"][data-type]');
+ if (!checkbox) return;
+ const typeValue = String(checkbox.dataset.type || '').trim().toLowerCase();
+ if (!typeValue) return;
+ if (checkbox.checked) {
+ currentTypes.add(typeValue);
+ } else {
+ currentTypes.delete(typeValue);
+ }
+ applySelectedTypesToUi();
+ applyFilters();
+ });
+ }
+
+ if (clearTypeFilterBtn) {
+ clearTypeFilterBtn.addEventListener('click', function() {
+ currentTypes = new Set();
+ applySelectedTypesToUi();
applyFilters();
});
}
@@ -833,16 +1315,68 @@
configuredTypes.forEach((t) => rowTypes.add(String(t || '').trim()));
const mergedTypes = Array.from(rowTypes).filter(Boolean).sort((a, b) => a.localeCompare(b, 'da'));
- if (mergedTypes.length === 0) return;
+ if (mergedTypes.length === 0) {
+ typeFilter.innerHTML = '';
+ renderTypeFilterOptions();
+ return;
+ }
- typeFilter.innerHTML = `` +
- mergedTypes.map(type => ``).join('');
+ typeFilter.innerHTML = mergedTypes.map(type => ``).join('');
+ applySelectedTypesToUi();
} catch (err) {
console.error('Failed to load case types', err);
}
}
- loadTypeFilters();
+ async function loadTypeFilterPreferences() {
+ try {
+ const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
+ if (!res.ok) return;
+ const data = await res.json();
+ const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
+ currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
+ applySelectedTypesToUi();
+ applyFilters();
+ } catch (err) {
+ console.error('Failed to load type filter preferences', err);
+ }
+ }
+
+ async function saveTypeFilterPreferences() {
+ if (!saveTypeFilterDefaultBtn) return;
+ const selected = getSelectedTypesFromUi();
+ saveTypeFilterDefaultBtn.disabled = true;
+ try {
+ const res = await fetch('/api/v1/sag/me/list-preferences', {
+ method: 'PATCH',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type_filters: selected }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ if (typeof showNotification === 'function') {
+ showNotification('Typefilter gemt som standard', 'success');
+ }
+ } catch (err) {
+ console.error('Failed to save type filter preferences', err);
+ if (typeof showNotification === 'function') {
+ showNotification('Kunne ikke gemme typefilter', 'error');
+ }
+ } finally {
+ saveTypeFilterDefaultBtn.disabled = false;
+ }
+ }
+
+ if (saveTypeFilterDefaultBtn) {
+ saveTypeFilterDefaultBtn.addEventListener('click', saveTypeFilterPreferences);
+ }
+
+ (async () => {
+ initMiniFilterSelections();
+ await loadTypeFilters();
+ await loadTypeFilterPreferences();
+ applyFilters();
+ })();
if (topAlertCustomerId) {
loadSagTopAlertsForCustomer(topAlertCustomerId);
diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py
index 6e7331e..6835057 100644
--- a/app/modules/telefoni/backend/router.py
+++ b/app/modules/telefoni/backend/router.py
@@ -331,6 +331,9 @@ async def yealink_established(
"direction": direction,
"contact": kontakt,
"recent_cases": contact_details.get("recent_cases", []),
+ "contact_cases": contact_details.get("contact_cases", []),
+ "company_cases": contact_details.get("company_cases", []),
+ "related_contacts": contact_details.get("related_contacts", []),
"last_call": contact_details.get("last_call"),
}
for user_id in user_ids:
diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py
index ca18dda..feb9036 100644
--- a/app/modules/telefoni/backend/service.py
+++ b/app/modules/telefoni/backend/service.py
@@ -176,18 +176,56 @@ class TelefoniService:
return bool(rows)
@staticmethod
- def get_contact_details(contact_id: int) -> dict:
- """
- Get extended contact details including:
- - Latest 3 open cases
- - Last call date
- """
+ def get_contact_details(contact_id: int, company_id: Optional[int] = None) -> dict:
+ """Get extended contact details for telefoni popups and call notifications."""
if not contact_id:
- return {"recent_cases": [], "last_call": None}
+ return {
+ "recent_cases": [],
+ "contact_cases": [],
+ "company_cases": [],
+ "related_contacts": [],
+ "last_call": None,
+ }
- # Get the 3 newest open cases for this contact
- cases_query = """
- SELECT
+ contact_row = execute_query_single(
+ """
+ SELECT
+ c.id,
+ c.first_name,
+ c.last_name,
+ c.email,
+ c.phone,
+ c.mobile,
+ c.title,
+ c.department,
+ c.is_active,
+ c.user_company,
+ (
+ SELECT cu.id
+ FROM contact_companies cc
+ JOIN customers cu ON cu.id = cc.customer_id
+ WHERE cc.contact_id = c.id
+ ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
+ LIMIT 1
+ ) AS company_id,
+ (
+ SELECT cu.name
+ FROM contact_companies cc
+ JOIN customers cu ON cu.id = cc.customer_id
+ WHERE cc.contact_id = c.id
+ ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
+ LIMIT 1
+ ) AS company
+ FROM contacts c
+ WHERE c.id = %s
+ """,
+ (contact_id,),
+ )
+
+ effective_company_id = company_id or (contact_row.get("company_id") if contact_row else None)
+
+ open_cases_query = """
+ SELECT
s.id,
s.titel,
s.created_at
@@ -200,11 +238,77 @@ class TelefoniService:
ORDER BY s.created_at DESC
LIMIT 3
"""
- cases = execute_query(cases_query, (contact_id,)) or []
+ recent_open_cases = execute_query(open_cases_query, (contact_id,)) or []
+
+ contact_cases_query = """
+ SELECT
+ s.id,
+ s.titel,
+ s.status,
+ s.customer_id,
+ cu.name AS customer_name,
+ s.created_at,
+ s.updated_at
+ FROM sag_sager s
+ INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
+ LEFT JOIN customers cu ON cu.id = s.customer_id
+ WHERE sk.contact_id = %s
+ AND s.deleted_at IS NULL
+ AND sk.deleted_at IS NULL
+ ORDER BY COALESCE(s.updated_at, s.created_at) DESC
+ LIMIT 5
+ """
+ contact_cases = execute_query(contact_cases_query, (contact_id,)) or []
+
+ company_cases = []
+ related_contacts = []
+ if effective_company_id:
+ company_cases_query = """
+ SELECT
+ s.id,
+ s.titel,
+ s.status,
+ s.customer_id,
+ cu.name AS customer_name,
+ s.created_at,
+ s.updated_at
+ FROM sag_sager s
+ LEFT JOIN customers cu ON cu.id = s.customer_id
+ WHERE s.customer_id = %s
+ AND s.deleted_at IS NULL
+ ORDER BY COALESCE(s.updated_at, s.created_at) DESC
+ LIMIT 5
+ """
+ company_cases = execute_query(company_cases_query, (effective_company_id,)) or []
+
+ related_contacts_query = """
+ SELECT
+ c.id,
+ c.first_name,
+ c.last_name,
+ c.email,
+ c.phone,
+ c.mobile,
+ c.title,
+ c.department,
+ c.is_active,
+ c.created_at,
+ c.updated_at,
+ ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
+ FROM contacts c
+ INNER JOIN contact_companies cc ON c.id = cc.contact_id
+ INNER JOIN customers cu ON cc.customer_id = cu.id
+ WHERE cc.customer_id = %s
+ AND c.id <> %s
+ AND c.is_active = TRUE
+ GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
+ ORDER BY c.last_name, c.first_name
+ LIMIT 8
+ """
+ related_contacts = execute_query(related_contacts_query, (effective_company_id, contact_id)) or []
- # Get the most recent call for this contact
last_call_query = """
- SELECT
+ SELECT
t.started_at,
t.bruger_id,
t.duration_sec,
@@ -218,7 +322,7 @@ class TelefoniService:
LIMIT 1
"""
last_call_row = execute_query_single(last_call_query, (contact_id,))
-
+
last_call_data = None
if last_call_row:
last_call_data = {
@@ -234,7 +338,46 @@ class TelefoniService:
"titel": case["titel"],
"created_at": case["created_at"],
}
- for case in cases
+ for case in recent_open_cases
+ ],
+ "contact_cases": [
+ {
+ "id": case["id"],
+ "titel": case["titel"],
+ "status": case.get("status"),
+ "customer_id": case.get("customer_id"),
+ "customer_name": case.get("customer_name"),
+ "created_at": case.get("created_at"),
+ "updated_at": case.get("updated_at"),
+ }
+ for case in contact_cases
+ ],
+ "company_cases": [
+ {
+ "id": case["id"],
+ "titel": case["titel"],
+ "status": case.get("status"),
+ "customer_id": case.get("customer_id"),
+ "customer_name": case.get("customer_name"),
+ "created_at": case.get("created_at"),
+ "updated_at": case.get("updated_at"),
+ }
+ for case in company_cases
+ ],
+ "related_contacts": [
+ {
+ "id": contact["id"],
+ "first_name": contact.get("first_name"),
+ "last_name": contact.get("last_name"),
+ "email": contact.get("email"),
+ "phone": contact.get("phone"),
+ "mobile": contact.get("mobile"),
+ "title": contact.get("title"),
+ "department": contact.get("department"),
+ "is_active": contact.get("is_active"),
+ "company_names": contact.get("company_names") or [],
+ }
+ for contact in related_contacts
],
"last_call": last_call_data,
}
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 66449b3..3b9c637 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -1517,6 +1517,18 @@ async def scan_document(file_path: str):
+
+
+ Menu visning (min konto)
+
+
+ Vælg hvilke hovedpunkter og underpunkter du vil se i topmenuen.
+
+
+
+
System Indstillinger
@@ -5317,12 +5329,173 @@ async function deactivateStage(stageId) {
loadPipelineStages();
}
+const MENU_VISIBILITY_GROUPS = [
+ {
+ title: 'Hovedmenu',
+ items: [
+ { key: 'menu-crm', label: 'CRM' },
+ { key: 'menu-sager', label: 'Sager' },
+ { key: 'menu-kalender', label: 'Kalender' },
+ { key: 'menu-support', label: 'Support' },
+ { key: 'menu-salg', label: 'Salg' },
+ { key: 'menu-okonomi', label: 'Økonomi' },
+ { key: 'menu-datamigration', label: 'Data migration' },
+ ],
+ },
+ {
+ title: 'CRM underpunkter',
+ items: [
+ { key: 'menu-crm-customers', label: 'Kunder' },
+ { key: 'menu-crm-contacts', label: 'Kontakter' },
+ { key: 'menu-crm-vendors', label: 'Leverandører' },
+ { key: 'menu-crm-links', label: 'Links' },
+ { key: 'menu-crm-locations', label: 'Lokaliteter' },
+ { key: 'menu-crm-opportunities', label: 'Muligheder' },
+ { key: 'menu-crm-pipeline', label: 'Pipeline' },
+ ],
+ },
+ {
+ title: 'Support underpunkter',
+ items: [
+ { key: 'menu-support-conversations', label: 'Mine Samtaler' },
+ { key: 'menu-support-tickets', label: 'Arkiverede Tickets' },
+ { key: 'menu-support-emails', label: 'Email' },
+ { key: 'menu-support-telefoni', label: 'Telefoni' },
+ { key: 'menu-support-mission', label: 'Mission Control' },
+ { key: 'menu-support-anydesk', label: 'AnyDesk Sessions' },
+ { key: 'menu-support-hardware', label: 'BMC Assets' },
+ { key: 'menu-support-hardware-customers', label: 'Kundehardware' },
+ { key: 'menu-support-eset', label: 'ESET Oversigt' },
+ { key: 'menu-support-manual', label: 'Manualer' },
+ ],
+ },
+ {
+ title: 'Salg/Økonomi underpunkter',
+ items: [
+ { key: 'menu-salg-orders', label: 'Ordre' },
+ { key: 'menu-salg-products', label: 'Produkter' },
+ { key: 'menu-salg-webshop', label: 'Webshop Administration' },
+ { key: 'menu-okonomi-time-queue', label: 'Time Queue' },
+ { key: 'menu-okonomi-supplier-invoices', label: 'Leverandør fakturaer' },
+ { key: 'menu-okonomi-prepaid', label: 'Prepaid Cards' },
+ { key: 'menu-okonomi-fixed-price', label: 'Fastpris Aftaler' },
+ { key: 'menu-okonomi-subscriptions', label: 'Abonnementer' },
+ ],
+ },
+ {
+ title: 'Data migration underpunkter',
+ items: [
+ { key: 'menu-datamigration-dashboard', label: 'Dashboard' },
+ { key: 'menu-datamigration-registrations', label: 'Registreringer' },
+ { key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
+ { key: 'menu-datamigration-employee-log', label: 'Medarbejder Log' },
+ { key: 'menu-datamigration-service-contract-wizard', label: 'Servicekontrakt Migration' },
+ { key: 'menu-datamigration-service-contract-report', label: 'Servicekontrakt Rapport' },
+ { key: 'menu-datamigration-orders', label: 'Ordrer' },
+ { key: 'menu-datamigration-customers', label: 'Kunder' },
+ ],
+ },
+];
+
+function setMenuVisibilityFeedback(message, type = 'muted') {
+ const el = document.getElementById('menuVisibilityFeedback');
+ if (!el) return;
+ const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
+ el.className = `small mb-2 ${cls}`;
+ el.textContent = message;
+}
+
+function renderMenuVisibilityGrid(hiddenKeys = []) {
+ const grid = document.getElementById('menuVisibilityGrid');
+ if (!grid) return;
+ const hiddenSet = new Set((hiddenKeys || []).map(v => String(v || '').trim().toLowerCase()));
+
+ grid.innerHTML = MENU_VISIBILITY_GROUPS.map(group => {
+ const checkboxes = group.items.map(item => {
+ const checked = hiddenSet.has(item.key.toLowerCase()) ? '' : 'checked';
+ return `
+
+
+
+
+ `;
+ }).join('');
+
+ return `
+
+
+ ${group.title}
+ ${checkboxes}
+
+
+ `;
+ }).join('');
+}
+
+async function loadMenuVisibilityPreferences() {
+ try {
+ setMenuVisibilityFeedback('Indlæser menuindstillinger...');
+ const response = await fetch('/api/v1/auth/me/menu-preferences', { credentials: 'include' });
+ if (!response.ok) {
+ throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse menuindstillinger'));
+ }
+ const data = await response.json();
+ renderMenuVisibilityGrid(data.hidden_menu_keys || []);
+ setMenuVisibilityFeedback('');
+ } catch (error) {
+ renderMenuVisibilityGrid([]);
+ setMenuVisibilityFeedback(error.message || 'Kunne ikke indlæse menuindstillinger', 'error');
+ }
+}
+
+async function saveMenuVisibilityPreferences() {
+ const saveBtn = document.getElementById('saveMenuVisibilityBtn');
+ const checks = Array.from(document.querySelectorAll('.menu-visibility-checkbox'));
+ const hiddenKeys = checks
+ .filter(el => !el.checked)
+ .map(el => String(el.value || '').trim().toLowerCase())
+ .filter(Boolean);
+
+ if (saveBtn) saveBtn.disabled = true;
+ setMenuVisibilityFeedback('Gemmer menuindstillinger...');
+
+ try {
+ const response = await fetch('/api/v1/auth/me/menu-preferences', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ hidden_menu_keys: hiddenKeys }),
+ });
+ if (!response.ok) {
+ throw new Error(await getErrorMessage(response, 'Kunne ikke gemme menuindstillinger'));
+ }
+
+ window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', {
+ detail: { hidden_menu_keys: hiddenKeys },
+ }));
+
+ setMenuVisibilityFeedback('Menuindstillinger gemt.', 'success');
+ showNotification('Menuindstillinger gemt', 'success');
+ } catch (error) {
+ setMenuVisibilityFeedback(error.message || 'Kunne ikke gemme menuindstillinger', 'error');
+ showNotification(error.message || 'Kunne ikke gemme menuindstillinger', 'error');
+ } finally {
+ if (saveBtn) saveBtn.disabled = false;
+ }
+}
+
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
loadUsers();
setupTagModalListeners();
loadPipelineStages();
+ loadMenuVisibilityPreferences();
+
+ const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn');
+ if (saveMenuVisibilityBtn) {
+ saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);
+ }
const telefoniTemplate = document.getElementById('telefoniActionTemplate');
const telefoniDefaultExt = document.getElementById('telefoniDefaultExtension');
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index fc1d4f2..594b23c 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -734,6 +734,21 @@
background-color: var(--accent-light);
color: var(--accent);
}
+
+ /* Make section headers in nav dropdowns clearly non-clickable labels */
+ #navbarNav .dropdown-menu li > .dropdown-header {
+ margin: 0.2rem 0 0.35rem;
+ padding: 0.35rem 0.75rem;
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--accent);
+ opacity: 0.8;
+ pointer-events: none;
+ user-select: none;
+ cursor: default;
+ }
.result-item {
padding: 0.75rem 1rem;
@@ -778,98 +793,98 @@
-
+
|