From 5ee962fdb3a0eeba7567db6f1e694e8b2e6538b4 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 2 May 2026 11:02:29 +0200 Subject: [PATCH] Release: mission day workflow, telefoni contact modal, fedex support overview, and economic sync dry-run --- app/contacts/frontend/contacts.html | 461 ++++++++++++++-- app/dashboard/backend/mission_service.py | 215 ++++++++ app/dashboard/frontend/mission_control.html | 497 +++++++++++++++++- app/modules/fedex/frontend/__init__.py | 0 .../fedex/frontend/fedex_overview.html | 351 +++++++++++++ app/modules/fedex/frontend/views.py | 14 + app/modules/links/templates/index.html | 265 +++++++++- app/modules/telefoni/backend/router.py | 29 +- app/modules/telefoni/templates/log.html | 468 ++++++++++++++++- app/services/economic_service.py | 19 +- app/settings/frontend/settings.html | 213 +++++++- app/shared/frontend/base.html | 123 ++++- app/system/backend/sync_router.py | 431 +++++++++------ main.py | 2 + 14 files changed, 2880 insertions(+), 208 deletions(-) create mode 100644 app/modules/fedex/frontend/__init__.py create mode 100644 app/modules/fedex/frontend/fedex_overview.html create mode 100644 app/modules/fedex/frontend/views.py diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html index 726422a..b3464cf 100644 --- a/app/contacts/frontend/contacts.html +++ b/app/contacts/frontend/contacts.html @@ -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 @@
- - +
Hurtig søgning
+
+ + + +
+ + +
- - - - - + + + + + + - '; + tbody.innerHTML = ''; 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 = ''; + tbody.innerHTML = ''; } 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 = ''; + if (!sortedContacts || sortedContacts.length === 0) { + tbody.innerHTML = ''; + 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 ? 'Aktiv' @@ -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 - ? `
${escapeHtml(contact.mobile)} - - -
` - : ''; - const phoneLine = !contact.mobile - ? `
${escapeHtml(contact.phone || '-')} - ${contact.phone ? `` : ''} -
` - : ''; - 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 ` @@ -782,17 +1016,38 @@ function displayContacts(contacts) { - - + - + + `; }).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; diff --git a/app/dashboard/backend/mission_service.py b/app/dashboard/backend/mission_service.py index b85d3e6..0b016cd 100644 --- a/app/dashboard/backend/mission_service.py +++ b/app/dashboard/backend/mission_service.py @@ -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": { diff --git a/app/dashboard/frontend/mission_control.html b/app/dashboard/frontend/mission_control.html index f83d5d8..86e22a9 100644 --- a/app/dashboard/frontend/mission_control.html +++ b/app/dashboard/frontend/mission_control.html @@ -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 @@
+
-
-
-
-
@@ -658,6 +903,26 @@
+
+
+

Dagen

+
Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.
+ +
+ + +
+ +
+
+
+ +
+
+
+
+
+
@@ -695,10 +960,6 @@
-
-
Live aktivitetsfeed
-
-
+{% endblock %} diff --git a/app/modules/fedex/frontend/views.py b/app/modules/fedex/frontend/views.py new file mode 100644 index 0000000..39f9168 --- /dev/null +++ b/app/modules/fedex/frontend/views.py @@ -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}, + ) diff --git a/app/modules/links/templates/index.html b/app/modules/links/templates/index.html index dbe0236..c6d22c7 100644 --- a/app/modules/links/templates/index.html +++ b/app/modules/links/templates/index.html @@ -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; + } } {% endblock %} @@ -353,7 +462,7 @@ + + + + + + + + `; + }).join(''); + + container.innerHTML = ` +
NavnKontakt InfoTitelFirmaerStatus + + Vigtig Info + + + + + + + + Handlinger
+
Loading...
@@ -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 = '
Kunne ikke indlæse kontakter
Kunne ikke indlæse kontakter
Ingen kontakter fundet
Ingen kontakter fundet
-
${safeEmail}
-
${smsLine}
+
+
+ Email + ${hasEmail + ? `${safeEmail}` + : 'Ikke angivet'} +
+
+ Telefon + ${safePhone} +
+
+ ${hasPreferredPhone + ? `` + : ''} + ${contact.mobile + ? `` + : ''} +
+
${safeTitle} + ${safeTitle} ${companyCount} ${companyDisplay !== '-' ? '
' + escapeHtml(companyDisplay) + '
' : ''}
${statusBadge} + ${updatedAt} + ${statusBadge}
+
+ + ${canOpen + ? `${escapeHtml(link.name || '-')}` + : `${escapeHtml(link.name || '-')}`} +
+
${escapeHtml(target)}${escapeHtml(env)}${escapeHtml(category)}${statusPill(status)} +
+ ${hasVault ? `` : ''} + + +
+
+ + + + + + + + + + + + ${rowsHtml} + + +
+ + `; + } + + function statusPill(status) { + if (status === 'ok') return 'OK'; + if (status === 'down') return 'Down'; + return 'Unknown'; + } + 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) => ``) + .join(''); + + select.innerHTML = `${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) => ``).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()]); {% endblock %} diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py index 8ec9101..6c00a2e 100644 --- a/app/modules/telefoni/backend/router.py +++ b/app/modules/telefoni/backend/router.py @@ -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") diff --git a/app/modules/telefoni/templates/log.html b/app/modules/telefoni/templates/log.html index b9876d5..7e077a2 100644 --- a/app/modules/telefoni/templates/log.html +++ b/app/modules/telefoni/templates/log.html @@ -67,6 +67,69 @@ + + + +