bmc_hub/app/shared/frontend/base.html
Christian 3452472ba9 Add migrations for recent cases, time tracking pause/resume, and user notes
- Created `sag_recent_cases` table to persist recently opened cases per user for quick access in the bottom bar.
- Added pause/resume support in `tmodule_times` by introducing `paused_at` and `pause_total_seconds` columns.
- Established `user_notes` table for personal user notes with indexing for active and updated notes, along with a trigger to update the `updated_at` timestamp on modifications.

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 11:28:12 +02:00

2363 lines
108 KiB
HTML

<!DOCTYPE html>
<html lang="da" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BMC Hub{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f4c75'/%3E%3Ctext x='32' y='42' text-anchor='middle' font-size='30' font-family='Arial, sans-serif' fill='white'%3EB%3C/text%3E%3C/svg%3E">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--bg-card-rgb: 255, 255, 255;
--text-primary: #2c3e50;
--text-primary-rgb: 44, 62, 80;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--border-radius: 12px;
--bottom-bar-height: 50px;
--bottom-bar-expanded-height: 50vh;
--bottom-bar-zindex: 1030;
}
[data-bs-theme="dark"] {
--bg-body: #212529;
--bg-card: #2c3034;
--bg-card-rgb: 44, 48, 52;
--text-primary: #f8f9fa;
--text-primary-rgb: 248, 249, 250;
--text-secondary: #adb5bd;
--accent: #3d8bfd; /* Lighter blue for dark mode */
--accent-light: #373b3e;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
body.bottom-bar-visible {
padding-bottom: calc(var(--bottom-bar-height) + 10px);
}
body.bottom-bar-visible.bottom-bar-expanded {
padding-bottom: calc(var(--bottom-bar-height) + 52vh);
}
.global-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--bottom-bar-zindex);
background: rgba(var(--bg-card-rgb), 0.85); /* Glassmorphism */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(var(--text-primary-rgb), 0.1);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.08);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
min-height: var(--bottom-bar-height);
padding: 0.5rem 1rem calc(0.5rem + env(safe-area-inset-bottom, 0px));
transform: translateY(calc(100% + 12px));
opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
isolation: isolate;
}
.global-bottom-bar::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
background: linear-gradient(120deg, rgba(15, 76, 117, 0.08), rgba(15, 76, 117, 0.02) 35%, rgba(255, 255, 255, 0));
z-index: -1;
}
.global-bottom-bar.is-visible {
transform: translateY(0);
opacity: 1;
}
.global-bottom-bar .bb-header {
min-height: calc(var(--bottom-bar-height) - 10px);
display: flex;
align-items: center;
gap: 0.45rem;
justify-content: space-between;
}
.global-bottom-bar .bb-zone {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.global-bottom-bar .bb-zone-left {
flex: 1;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: none;
}
.global-bottom-bar .bb-zone-left::-webkit-scrollbar {
display: none;
}
.global-bottom-bar .bb-zone-center {
flex: 0 0 auto;
justify-content: center;
}
.global-bottom-bar .bb-zone-right {
flex: 0 0 auto;
justify-content: flex-end;
}
.global-bottom-bar .bb-count-line {
flex: 1;
background: transparent;
color: var(--text-primary);
padding: 0 0.25rem;
font-size: 0.84rem;
font-weight: 600;
line-height: 1.35;
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Cleaner look without scrollbar */
display: flex;
align-items: center;
gap: 0.6rem;
}
.global-bottom-bar .bb-count-line::-webkit-scrollbar {
display: none;
}
.global-bottom-bar .bb-sheet-toggle {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: transparent;
color: var(--text-primary);
border-radius: 50%;
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
transition: all 0.2s ease;
}
.global-bottom-bar .bb-sheet-toggle:hover {
background: var(--accent-light);
transform: translateY(-1px);
}
.global-bottom-bar .bb-sheet-toggle span { display: none; }
.global-bottom-bar .bb-sheet-toggle i {
transition: transform 0.25s ease;
}
.global-bottom-bar .bb-action-btn {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: var(--bg-card);
color: var(--text-primary);
border-radius: 999px;
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
transition: all 0.2s ease;
}
.global-bottom-bar .dropdown-menu {
z-index: calc(var(--bottom-bar-zindex) + 40);
}
.global-bottom-bar .bb-action-btn:hover {
border-color: rgba(var(--text-primary-rgb), 0.25);
background: var(--accent-light);
color: var(--accent);
}
.global-bottom-bar .bb-search-btn {
min-width: 40px;
justify-content: center;
}
.global-bottom-bar .bb-activity-chip {
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
border-radius: 999px;
padding: 0.28rem 0.7rem;
font-size: 0.8rem;
background: var(--bg-card);
color: var(--text-primary);
display: inline-flex;
align-items: center;
gap: 0.3rem;
white-space: nowrap;
}
.global-bottom-bar .bb-activity-chip.is-hidden {
display: none;
}
.global-bottom-bar .bb-notification-count {
background: var(--accent);
color: #fff;
border-radius: 999px;
min-width: 1.2rem;
height: 1.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
padding: 0 0.3rem;
}
.global-bottom-bar.is-expanded .bb-sheet-toggle i {
transform: rotate(180deg);
}
.global-bottom-bar .bb-chip {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: var(--accent-light);
color: var(--text-primary);
border-radius: 999px;
padding: 0.35rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: all 0.2s ease;
}
.global-bottom-bar .bb-chip .bb-chip-label {
opacity: 0.95;
}
.global-bottom-bar .bb-chip .bb-chip-bubble {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.35rem;
border-radius: 999px;
padding: 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
background: rgba(var(--text-primary-rgb), 0.15);
color: currentColor;
}
.global-bottom-bar .bb-chip:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.08);
border-color: rgba(var(--text-primary-rgb), 0.2);
}
.global-bottom-bar .bb-chip.is-active {
background: var(--accent);
color: white;
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
}
.global-bottom-bar .bb-chip.sev-ok {
background: rgba(25, 135, 84, 0.14);
border-color: rgba(25, 135, 84, 0.35);
color: #146c43;
}
.global-bottom-bar .bb-chip.sev-ok .bb-chip-bubble {
background: rgba(25, 135, 84, 0.2);
}
.global-bottom-bar .bb-chip.sev-warn {
background: rgba(255, 193, 7, 0.22);
border-color: rgba(255, 193, 7, 0.5);
color: #8a6d00;
}
.global-bottom-bar .bb-chip.sev-warn .bb-chip-bubble {
background: rgba(255, 193, 7, 0.3);
}
.global-bottom-bar .bb-chip.sev-critical {
background: rgba(220, 53, 69, 0.14);
border-color: rgba(220, 53, 69, 0.4);
color: #b02a37;
}
.global-bottom-bar .bb-chip.sev-critical .bb-chip-bubble {
background: rgba(220, 53, 69, 0.22);
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-ok {
color: #75d39a;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-warn {
color: #ffd166;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.sev-critical {
color: #ff9aa2;
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip .bb-chip-bubble {
background: rgba(255, 255, 255, 0.12);
}
[data-bs-theme="dark"] .global-bottom-bar .bb-chip.is-active {
color: #fff;
}
.global-bottom-bar .bb-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.global-bottom-bar .bb-detail-line {
display: none;
margin-top: 0.5rem;
background: transparent;
padding: 0 0.5rem;
font-size: 0.82rem;
color: var(--text-secondary);
white-space: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.global-bottom-bar.is-expanded .bb-detail-line {
display: block;
}
.global-bottom-bar:not(.is-expanded) .bb-header {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.global-bottom-bar:not(.is-expanded) .bb-header::-webkit-scrollbar {
display: none;
}
.global-bottom-bar .bb-sheet-panel {
margin-top: 0.4rem;
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.28s ease, opacity 0.2s ease;
}
.global-bottom-bar.is-expanded .bb-sheet-panel {
max-height: min(52vh, 460px);
opacity: 1;
}
.global-bottom-bar .bb-sheet-inner {
background: var(--bg-card);
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
border-radius: 14px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
min-height: 240px;
max-height: min(52vh, 420px);
overflow: hidden;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
margin-top: 0.5rem;
}
.global-bottom-bar .bb-side-tabs {
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
background: rgba(var(--text-primary-rgb), 0.03);
padding: 0.75rem 0.5rem;
display: grid;
gap: 0.4rem;
align-content: start;
}
.global-bottom-bar .bb-tab-btn {
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
text-align: left;
font-size: 0.85rem;
font-weight: 600;
padding: 0.5rem 0.75rem;
line-height: 1.3;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.global-bottom-bar .bb-tab-btn i {
font-size: 1rem;
opacity: 0.7;
}
.global-bottom-bar .bb-tab-btn:hover {
background: rgba(var(--text-primary-rgb), 0.05);
color: var(--text-primary);
}
.global-bottom-bar .bb-tab-btn.is-active {
background: var(--bg-card);
color: var(--accent);
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
font-weight: 700;
}
.global-bottom-bar .bb-tab-btn.is-active i {
opacity: 1;
color: var(--accent);
}
.global-bottom-bar .bb-tab-content {
padding: 1.2rem;
overflow: auto;
}
.global-bottom-bar .bb-tab-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.global-bottom-bar .bb-tab-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.global-bottom-bar .bb-tab-list li {
border-left: 4px solid var(--accent);
background: var(--accent-light);
border-radius: 6px 8px 8px 6px;
padding: 0.75rem 1rem;
font-size: 0.88rem;
line-height: 1.4;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.global-bottom-bar .bb-tab-list li:hover {
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
}
#bbSwitchCaseModal .modal-content {
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
border-radius: 14px;
background: var(--bg-card);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
}
#bbSwitchCaseModal .modal-header {
border-bottom: 1px solid rgba(var(--text-primary-rgb), 0.08);
}
#bbSwitchCaseModal .list-group-item {
border-color: rgba(var(--text-primary-rgb), 0.08);
transition: background-color 0.2s ease;
}
#bbSwitchCaseModal .list-group-item:hover {
background-color: rgba(var(--text-primary-rgb), 0.03);
}
#bbQuickNoteInput {
border-color: rgba(var(--text-primary-rgb), 0.12);
}
#bbQuickNoteInput:focus {
border-color: var(--accent);
box-shadow: 0 0 0 0.2rem rgba(15, 76, 117, 0.12);
}
@media (max-width: 767.98px) {
:root {
--bottom-bar-expanded-height: 56vh;
}
.global-bottom-bar {
min-height: 56px;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.global-bottom-bar .bb-sheet-inner {
grid-template-columns: 1fr;
min-height: 240px;
}
.global-bottom-bar.is-expanded .bb-header {
flex-wrap: wrap;
row-gap: 0.4rem;
}
.global-bottom-bar.is-expanded .bb-zone-left,
.global-bottom-bar.is-expanded .bb-zone-center,
.global-bottom-bar.is-expanded .bb-zone-right {
flex: 1 1 100%;
justify-content: flex-start;
}
.global-bottom-bar .bb-side-tabs {
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
grid-template-columns: repeat(5, minmax(90px, 1fr));
overflow-x: auto;
}
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
padding: 1rem 0;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
font-weight: 500;
margin: 0 0.2rem;
}
.nav-link:hover, .nav-link.active {
background-color: var(--accent-light);
color: var(--accent);
}
.card {
border: none;
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);
}
.card:hover {
transform: translateY(-2px);
}
.stat-card h3 {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.table {
color: var(--text-primary);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
border-bottom-width: 1px;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background-color: var(--accent);
border-color: var(--accent);
padding: 0.6rem 1.5rem;
border-radius: 8px;
}
.btn-primary:hover {
background-color: #0a3655;
border-color: #0a3655;
}
.header-search {
background: var(--bg-body);
border: 1px solid rgba(0,0,0,0.1);
padding: 0.6rem 1.2rem;
border-radius: 8px;
width: 300px;
color: var(--text-primary);
}
.header-search:focus {
outline: none;
border-color: var(--accent);
}
.dropdown-menu {
border: none;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border-radius: 12px;
padding: 0.5rem;
background-color: var(--bg-card);
}
/* Nested dropdown support - simplified click-based approach */
.dropdown-submenu {
position: relative;
}
.dropdown-submenu .dropdown-menu {
position: absolute;
top: 0;
left: 100%;
margin-left: 0.1rem;
margin-top: -0.5rem;
display: none;
}
.dropdown-submenu .dropdown-menu.show {
display: block;
}
.dropdown-submenu .dropdown-toggle::after {
display: none;
}
.dropdown-submenu > a {
display: flex;
align-items: center;
justify-content: space-between;
}
.dropdown-item {
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.dropdown-item:hover {
background-color: var(--accent-light);
color: var(--accent);
}
.result-item {
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
border: 1px solid transparent;
}
.result-item:hover {
background-color: var(--accent-light);
border-color: var(--accent);
}
.result-item.selected {
background-color: var(--accent-light);
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.1);
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid px-4">
<a class="navbar-brand d-flex align-items-center" href="/">
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;">
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
</div>
BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mx-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-people me-2"></i>CRM
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
<li><a class="dropdown-item py-2" href="/links">Links</a></li>
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
<li><a class="dropdown-item py-2" href="#">Leads</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="/sag">
<i class="bi bi-list-check me-2"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/calendar">
<i class="bi bi-calendar3 me-2"></i>Kalender
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-headset me-2"></i>Support
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</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>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-cart3 me-2"></i>Salg
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Tilbud</a></li>
<li><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-currency-dollar me-2"></i>Økonomi
</a>
<ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="/emails">
<i class="bi bi-envelope me-2"></i>Email
</a>
</li>
</ul>
<div class="d-flex align-items-center gap-3">
<div class="dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-clock-history me-2"></i>Data migration
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</div>
<button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
<i class="bi bi-question-circle"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
<i class="bi bi-moon-fill"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="globalRemindersBtn" style="background: var(--accent-light); color: var(--accent);" title="Åbn reminders">
<i class="bi bi-bell"></i>
</button>
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li>
<li><a class="dropdown-item py-2" href="/settings"><i class="bi bi-gear me-2"></i>Indstillinger</a></li>
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2 text-danger" href="#" onclick="logoutUser(event)"><i class="bi bi-box-arrow-right me-2"></i>Log ud</a></li>
</ul>
</div>
</div>
</div>
</div>
</nav>
<!-- Global Search Modal (Cmd+K) -->
<div class="modal fade" id="globalSearchModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width: 85vw; width: 85vw;">
<div class="modal-content" style="border: none; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); height: 85vh;">
<div class="modal-body p-0 d-flex flex-column" style="height: 100%;">
<div class="p-4 border-bottom" style="background: var(--bg-card);">
<div class="position-relative">
<i class="bi bi-search position-absolute" style="left: 20px; top: 50%; transform: translateY(-50%); font-size: 1.5rem; color: var(--text-secondary);"></i>
<input
type="text"
id="globalSearchInput"
class="form-control form-control-lg ps-5"
placeholder="Søg efter kunder, sager, produkter... (tryk Esc for at lukke)"
style="border: none; background: var(--bg-body); font-size: 1.25rem; padding: 1rem 1rem 1rem 4rem; border-radius: 12px;"
autofocus
>
</div>
<div class="d-flex gap-2 mt-3">
<span class="badge bg-secondary bg-opacity-10 text-secondary">⌘K for at åbne</span>
<span class="badge bg-secondary bg-opacity-10 text-secondary">ESC for at lukke</span>
<span class="badge bg-secondary bg-opacity-10 text-secondary">↑↓ for at navigere</span>
</div>
</div>
<div class="row g-0 flex-grow-1" style="overflow-y: auto;">
<!-- Search Results & Workflows (3/4 width) -->
<div class="col-lg-9 p-4" style="border-right: 1px solid rgba(0,0,0,0.1);">
<!-- Contextual Workflows Section -->
<div id="workflowActions" style="display: none;" class="mb-4">
<h6 class="text-muted text-uppercase small fw-bold mb-3">
<i class="bi bi-lightning-charge me-2"></i>Hurtige Handlinger
</h6>
<div id="workflowButtons" class="d-flex flex-wrap gap-2">
<!-- Dynamic workflow buttons -->
</div>
</div>
<div id="searchResults">
<!-- Empty State -->
<div id="emptyState" class="text-center py-5">
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
<p class="text-muted mt-3">Tryk <kbd>⌘K</kbd> eller begynd at skrive...</p>
</div>
<!-- CRM Results -->
<div id="crmResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-people me-2"></i>CRM
</h6>
<a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a>
</div>
<div class="result-items">
<!-- Dynamic results will be inserted here -->
</div>
</div>
<!-- Support Results -->
<div id="supportResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-headset me-2"></i>Support
</h6>
<a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a>
</div>
<div class="result-items">
<!-- Dynamic results will be inserted here -->
</div>
</div>
<!-- Email Results -->
<div id="emailResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-envelope me-2"></i>Email
</h6>
<a href="/emails" class="btn btn-sm btn-outline-primary">
<i class="bi bi-envelope-open me-1"></i>Åbn Email
</a>
</div>
<div class="result-items">
<!-- Dynamic results will be inserted here -->
</div>
</div>
<!-- Sales Results -->
<div id="salesResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-cart3 me-2"></i>Salg
</h6>
<a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a>
</div>
<div class="result-items">
<!-- Dynamic results will be inserted here -->
</div>
</div>
<!-- Finance Results -->
<div id="financeResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-currency-dollar me-2"></i>Økonomi
</h6>
<a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a>
</div>
<div class="result-items">
<!-- Dynamic results will be inserted here -->
</div>
</div>
</div>
</div>
<!-- Live Boxes Sidebar (1/4 width) -->
<div class="col-lg-3 p-3" style="background: var(--bg-body); overflow-y: auto;">
<!-- Recent Activity Section -->
<div class="mb-4">
<h6 class="text-uppercase small fw-bold mb-3" style="color: var(--text-primary);">
<i class="bi bi-clock-history me-2"></i>Seneste Aktivitet
</h6>
<div id="recentActivityList">
<!-- Dynamic activity items -->
</div>
</div>
<hr class="my-3">
<!-- Sales Box -->
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="text-uppercase small fw-bold mb-0">
<i class="bi bi-cart3 me-2"></i>Sales
</h6>
<i class="bi bi-arrow-up-right"></i>
</div>
<div id="salesBox">
<div class="mb-2">
<p class="mb-0 small opacity-75">Aktive ordrer</p>
<h4 class="mb-0 fw-bold">-</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Månedens salg</p>
<h5 class="mb-0 fw-bold">- kr</h5>
</div>
</div>
</div>
<!-- Support Box -->
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white;">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="text-uppercase small fw-bold mb-0">
<i class="bi bi-headset me-2"></i>Support
</h6>
<i class="bi bi-arrow-up-right"></i>
</div>
<div id="supportBox">
<div class="mb-2">
<p class="mb-0 small opacity-75">Åbne sager</p>
<h4 class="mb-0 fw-bold">-</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Gns. svartid</p>
<h5 class="mb-0 fw-bold">- min</h5>
</div>
</div>
</div>
<!-- Økonomi Box -->
<div class="live-box mb-3 p-3 rounded" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white;">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="text-uppercase small fw-bold mb-0">
<i class="bi bi-currency-dollar me-2"></i>Økonomi
</h6>
<i class="bi bi-arrow-up-right"></i>
</div>
<div id="financeBox">
<div class="mb-2">
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
<h4 class="mb-0 fw-bold">-</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Samlet beløb</p>
<h5 class="mb-0 fw-bold">- kr</h5>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% block content_wrapper %}
<div class="container-fluid px-4 py-4">
{% block content %}{% endblock %}
</div>
{% endblock %}
<div id="globalBottomBar" class="global-bottom-bar" hidden>
<div class="bb-header">
<div class="bb-zone bb-zone-left" role="status" aria-live="polite">
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-label">Ulæste mails</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Ulæste mails: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-label">Hastesager</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Hastesager: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-label">Uden ansvarlig</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Uden ansvarlig: 0</span></button>
</div>
<div class="bb-zone bb-zone-center">
<button id="bbSearchBtn" class="bb-action-btn bb-search-btn" type="button" title="Søg (Cmd/Ctrl+K)">
<i class="bi bi-search"></i>
</button>
</div>
<div class="bb-zone bb-zone-right">
<button id="bbActiveTimerChip" class="bb-activity-chip is-hidden" type="button" title="Aktiv timer">
<i class="bi bi-stopwatch"></i>
<span id="bbActiveTimerText">Ingen aktiv timer</span>
</button>
<button id="bbNotificationsBtn" class="bb-activity-chip" type="button" title="Notifikationer">
<i class="bi bi-bell"></i>
<span id="bbNotificationsCount" class="bb-notification-count">0</span>
</button>
<button id="bbTimerPauseBtn" class="bb-action-btn" type="button" title="Pause timer"><i class="bi bi-pause-fill"></i></button>
<button id="bbTimerStopBtn" class="bb-action-btn" type="button" title="Stop timer"><i class="bi bi-stop-fill"></i></button>
<button id="bbTimerSwitchBtn" class="bb-action-btn" type="button" title="Skift sag"><i class="bi bi-arrow-left-right"></i></button>
<button id="bbSheetToggle" class="bb-sheet-toggle" type="button" aria-expanded="false" aria-controls="bbSheetPanel" aria-label="Toggle detaljer">
<span>Info</span>
<i class="bi bi-chevron-up" aria-hidden="true"></i>
</button>
</div>
</div>
<div id="bbCountDetail" class="bb-detail-line" role="status" aria-live="polite"><i class="bi bi-info-circle me-1 opacity-75"></i> Klik på en kategori for at se detaljer</div>
<div id="bbSheetPanel" class="bb-sheet-panel" aria-hidden="true">
<div class="bb-sheet-inner">
<div class="bb-side-tabs" role="tablist" aria-label="Bundbar kategorier">
<button class="bb-tab-btn is-active" type="button" data-bb-tab="overview" role="tab" aria-selected="true"><i class="bi bi-bell"></i> Overblik</button>
<button class="bb-tab-btn" type="button" data-bb-tab="timer" role="tab" aria-selected="false"><i class="bi bi-stopwatch"></i> Timer</button>
<button class="bb-tab-btn" type="button" data-bb-tab="messages" role="tab" aria-selected="false"><i class="bi bi-chat-dots"></i> Beskeder</button>
<button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button>
<button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button>
<!-- Vises kun for chefer, men her i markup -->
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-person-workspace"></i> Chef</button>
</div>
<div class="bb-tab-content" role="tabpanel" aria-live="polite">
<div id="bbTabTitle" class="bb-tab-title"><i class="bi bi-bell me-1 text-accent"></i> <span class="bb-tab-title-text">Overblik</span></div>
<div id="bbTabInnerContent">
<ul id="bbTabList" class="bb-tab-list">
<li>Venter på data...</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bbSwitchCaseModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbSwitchCaseModalLabel">
<div class="modal-dialog modal-dialog-scrollable modal-lg modal-dialog-bottom">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbSwitchCaseModalLabel"><i class="bi bi-arrow-left-right me-2"></i>Skift sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbSwitchCaseStatus" class="small text-muted mb-3">Henter data...</div>
<div id="bbSwitchTimerActions" class="mb-3 d-none">
<div class="fw-semibold mb-2">Aktiv timer fundet</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-warning btn-sm" data-bb-switch-action="pause-now"><i class="bi bi-pause-fill me-1"></i>Pause nu</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-bb-switch-action="stop-now"><i class="bi bi-stop-fill me-1"></i>Stop nu</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bb-switch-action="continue-unchanged">Fortsæt uændret</button>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-lg-6">
<h6 class="mb-2">Dine aktive/pausede timere</h6>
<div id="bbSwitchTimersList" class="list-group small">
<div class="list-group-item text-muted">Henter timere...</div>
</div>
</div>
<div class="col-12 col-lg-6">
<h6 class="mb-2">Seneste sager</h6>
<div id="bbSwitchRecentCasesList" class="list-group small">
<div class="list-group-item text-muted">Henter sager...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bbNoteTargetModal" tabindex="-1" aria-hidden="true" aria-labelledby="bbNoteTargetModalLabel">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bbNoteTargetModalLabel"><i class="bi bi-journal-plus me-2"></i>Indsæt note</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="bbNoteTargetStatus" class="small text-muted mb-3">Vælg mål og indsæt tekst.</div>
<div class="mb-3">
<label for="bbNoteTargetIdInput" id="bbNoteTargetIdLabel" class="form-label">Mål ID</label>
<input type="number" class="form-control" id="bbNoteTargetIdInput" min="1" step="1" placeholder="ID">
</div>
<div class="mb-3 d-none" id="bbNoteTargetFieldWrap">
<label for="bbNoteTargetFieldSelect" id="bbNoteTargetFieldLabel" class="form-label">Felt</label>
<select id="bbNoteTargetFieldSelect" class="form-select"></select>
</div>
<div class="mb-3">
<label for="bbNoteTargetTextInput" class="form-label">Tekst der indsættes</label>
<textarea id="bbNoteTargetTextInput" class="form-control" rows="6" placeholder="Tekst fra note"></textarea>
</div>
<div class="form-text">Tip: brug kun den del af noten du vil gemme i målet.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="bbNoteTargetSubmitBtn">Indsæt</button>
</div>
</div>
</div>
</div>
<script>
window.addEventListener('unhandledrejection', function(event) {
const reason = event && event.reason;
const msg = String((reason && reason.message) || reason || '');
const stack = String((reason && reason.stack) || '');
const combined = (msg + '\n' + stack).toLowerCase();
// Known Safari/extension autofill overlay crash; ignore noisy external script rejection.
if (combined.includes('bootstrap-autofill-overlay.js') || combined.includes('autocompletetype.includes')) {
event.preventDefault();
}
});
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.22"></script>
<script>
// Dark Mode Toggle Logic
const darkModeToggle = document.getElementById('darkModeToggle');
const htmlElement = document.documentElement;
const icon = darkModeToggle.querySelector('i');
// Check local storage
if (localStorage.getItem('theme') === 'dark') {
htmlElement.setAttribute('data-bs-theme', 'dark');
icon.classList.replace('bi-moon-fill', 'bi-sun-fill');
}
darkModeToggle.addEventListener('click', () => {
if (htmlElement.getAttribute('data-bs-theme') === 'dark') {
htmlElement.setAttribute('data-bs-theme', 'light');
localStorage.setItem('theme', 'light');
icon.classList.replace('bi-sun-fill', 'bi-moon-fill');
} else {
htmlElement.setAttribute('data-bs-theme', 'dark');
localStorage.setItem('theme', 'dark');
icon.classList.replace('bi-moon-fill', 'bi-sun-fill');
}
});
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
let selectedResultIndex = -1;
let allResults = [];
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn');
const contextManualBtn = document.getElementById('contextManualBtn');
const remindersBubbleBtn = document.getElementById('globalRemindersBtn');
const profileModalEl = document.getElementById('profileModal');
const profileModalInstance = profileModalEl ? new bootstrap.Modal(profileModalEl) : null;
const globalSearchInput = document.getElementById('globalSearchInput');
function getCurrentModuleContext() {
const path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/sag')) return 'sag';
if (path.startsWith('/hardware')) return 'hardware';
if (path.startsWith('/emails')) return 'mail';
if (path.startsWith('/ordre')) return 'salg';
if (path.startsWith('/customers') || path.startsWith('/contacts')) return 'crm';
return '';
}
function openGlobalSearchModal() {
searchModal.show();
setTimeout(() => {
if (globalSearchInput) {
globalSearchInput.focus();
}
loadLiveStats();
loadRecentActivity();
}, 300);
}
function openRemindersModalTab() {
if (!profileModalInstance || !profileModalEl) {
return;
}
profileModalInstance.show();
setTimeout(() => {
const remindersTabBtn = document.getElementById('profile-reminders-tab');
if (remindersTabBtn) {
bootstrap.Tab.getOrCreateInstance(remindersTabBtn).show();
}
loadReminderPreferences();
loadProfileReminders();
}, 220);
}
if (searchBubbleBtn) {
searchBubbleBtn.addEventListener('click', (e) => {
e.preventDefault();
openGlobalSearchModal();
});
}
if (remindersBubbleBtn) {
remindersBubbleBtn.addEventListener('click', (e) => {
e.preventDefault();
openRemindersModalTab();
});
}
if (contextManualBtn) {
contextManualBtn.addEventListener('click', (e) => {
e.preventDefault();
const module = getCurrentModuleContext();
if (module) {
window.location.href = `/manual?module=${encodeURIComponent(module)}`;
return;
}
window.location.href = '/manual';
});
}
// Search input listener with debounce
let searchTimeout;
if (globalSearchInput) {
globalSearchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
selectedResultIndex = -1;
performGlobalSearch(e.target.value);
}, 300);
});
// Keyboard navigation
globalSearchInput.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
navigateResults(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
navigateResults(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (navigateToSagFromScan(e.target.value)) {
return;
}
selectCurrentResult();
}
});
}
// Keyboard shortcut: Cmd+K or Ctrl+K
document.addEventListener('keydown', (e) => {
// Cmd+K / Ctrl+K for global search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
openGlobalSearchModal();
}
// '+' key for QuickCreate (not in input fields)
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
e.preventDefault();
openQuickCreateModal();
}
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
e.preventDefault();
openQuickCreateModal();
}
// ESC to close
if (e.key === 'Escape') {
searchModal.hide();
}
});
// QuickCreate modal opener function
function openQuickCreateModal() {
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
quickCreateModal.show();
setTimeout(() => {
const textInput = document.getElementById('quickCreateText');
if (textInput) {
textInput.focus();
}
}, 300);
}
// QuickCreate button click handler
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
e.preventDefault();
openQuickCreateModal();
});
// Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
if (globalSearchInput) {
globalSearchInput.value = '';
}
selectedEntity = null;
document.getElementById('emptyState').style.display = 'block';
document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
});
});
// Load live statistics for the three boxes
async function loadLiveStats() {
try {
const response = await fetch('/api/v1/live-stats');
const data = await response.json();
// Update Sales Box
const salesBox = document.getElementById('salesBox');
salesBox.innerHTML = `
<div class="mb-2">
<p class="mb-0 small opacity-75">Aktive ordrer</p>
<h4 class="mb-0 fw-bold">${data.sales.active_orders}</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Månedens salg</p>
<h5 class="mb-0 fw-bold">${data.sales.monthly_sales.toLocaleString('da-DK')} kr</h5>
</div>
`;
// Update Support Box
const supportBox = document.getElementById('supportBox');
supportBox.innerHTML = `
<div class="mb-2">
<p class="mb-0 small opacity-75">Åbne sager</p>
<h4 class="mb-0 fw-bold">${data.support.open_tickets}</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Gns. svartid</p>
<h5 class="mb-0 fw-bold">${data.support.avg_response_time} min</h5>
</div>
`;
// Update Finance Box
const financeBox = document.getElementById('financeBox');
financeBox.innerHTML = `
<div class="mb-2">
<p class="mb-0 small opacity-75">Ubetalte fakturaer</p>
<h4 class="mb-0 fw-bold">${data.finance.unpaid_invoices_count}</h4>
</div>
<div class="mb-2">
<p class="mb-0 small opacity-75">Samlet beløb</p>
<h5 class="mb-0 fw-bold">${data.finance.unpaid_invoices_amount.toLocaleString('da-DK')} kr</h5>
</div>
`;
} catch (error) {
console.error('Error loading live stats:', error);
}
}
// Load recent activity
async function loadRecentActivity() {
try {
const response = await fetch('/api/v1/recent-activity');
const activities = await response.json();
const activityList = document.getElementById('recentActivityList');
if (activities.length === 0) {
activityList.innerHTML = '<p class="small text-muted">Ingen nylig aktivitet</p>';
return;
}
activityList.innerHTML = activities.map(activity => {
const timeAgo = getTimeAgo(new Date(activity.created_at));
const label = activity.activity_type === 'customer' ? 'Kunde' :
activity.activity_type === 'contact' ? 'Kontakt' : 'Leverandør';
return `
<div class="activity-item mb-2 p-2 rounded" style="background: var(--bg-card); border-left: 3px solid var(--accent);">
<div class="d-flex align-items-start">
<i class="${activity.icon} text-${activity.color} me-2" style="font-size: 1.1rem;"></i>
<div class="flex-grow-1">
<p class="mb-0 small fw-bold" style="color: var(--text-primary);">${activity.name}</p>
<p class="mb-0 small text-muted">${label}${timeAgo}</p>
</div>
</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading recent activity:', error);
}
}
// Helper function to format time ago
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
let interval = seconds / 31536000;
if (interval > 1) return Math.floor(interval) + ' år siden';
interval = seconds / 2592000;
if (interval > 1) return Math.floor(interval) + ' mdr siden';
interval = seconds / 86400;
if (interval > 1) return Math.floor(interval) + ' dage siden';
interval = seconds / 3600;
if (interval > 1) return Math.floor(interval) + ' timer siden';
interval = seconds / 60;
if (interval > 1) return Math.floor(interval) + ' min siden';
return 'Lige nu';
}
// Helper function to escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
let selectedEntity = null;
// Navigate through search results
function navigateResults(direction) {
const resultItems = document.querySelectorAll('.result-item');
allResults = Array.from(resultItems);
if (allResults.length === 0) return;
// Remove previous selection
if (selectedResultIndex >= 0 && allResults[selectedResultIndex]) {
allResults[selectedResultIndex].classList.remove('selected');
}
// Update index
selectedResultIndex += direction;
// Wrap around
if (selectedResultIndex < 0) {
selectedResultIndex = allResults.length - 1;
} else if (selectedResultIndex >= allResults.length) {
selectedResultIndex = 0;
}
// Add selection
if (allResults[selectedResultIndex]) {
allResults[selectedResultIndex].classList.add('selected');
allResults[selectedResultIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
// Select current result and navigate
function selectCurrentResult() {
if (selectedResultIndex >= 0 && allResults[selectedResultIndex]) {
allResults[selectedResultIndex].click();
} else if (allResults.length > 0) {
// No selection, select first result
allResults[0].click();
}
}
function extractSagIdFromScanToken(value) {
const cleaned = String(value || '').toUpperCase().replace(/\s+/g, ' ').trim();
if (!cleaned) return null;
// Scanner tokens from work order and hardware labels
const workOrderMatch = cleaned.match(/\bBMCSCAN-WO-S(\d+)\b/);
if (workOrderMatch) return parseInt(workOrderMatch[1], 10);
const hardwareMatch = cleaned.match(/\bBMCSCAN-HW-(\d+)\b/);
if (hardwareMatch) return parseInt(hardwareMatch[1], 10);
return null;
}
function navigateToSagFromScan(value) {
const sagId = extractSagIdFromScanToken(value);
if (!sagId || Number.isNaN(sagId)) {
return false;
}
window.location.href = `/sag/${sagId}`;
return true;
}
// Global search function
async function performGlobalSearch(query) {
if (navigateToSagFromScan(query)) {
return;
}
if (!query || query.trim().length < 2) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
return;
}
console.log('🔍 Performing global search:', query);
document.getElementById('emptyState').style.display = 'none';
let hasResults = false;
try {
// Search customers
const customerResponse = await fetch(`/api/v1/customers?search=${encodeURIComponent(query)}&limit=5`);
const customerData = await customerResponse.json();
const crmResults = document.getElementById('crmResults');
if (customerData.customers && customerData.customers.length > 0) {
hasResults = true;
crmResults.style.display = 'block';
const resultsList = crmResults.querySelector('.result-items');
if (resultsList) {
resultsList.innerHTML = customerData.customers.map(customer => `
<div class="result-item" onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
<div>
<div class="fw-bold">${escapeHtml(customer.name)}</div>
<div class="small text-muted">
<i class="bi bi-building me-1"></i>Kunde
${customer.cvr_number ? ` • CVR: ${customer.cvr_number}` : ''}
${customer.email ? `${customer.email}` : ''}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`).join('');
}
} else {
crmResults.style.display = 'none';
}
// Search contacts
try {
const contactsResponse = await fetch(`/api/v1/contacts?search=${encodeURIComponent(query)}&limit=5`);
const contactsData = await contactsResponse.json();
if (contactsData.contacts && contactsData.contacts.length > 0) {
hasResults = true;
const supportResults = document.getElementById('supportResults');
supportResults.style.display = 'block';
const supportList = supportResults.querySelector('.result-items');
if (supportList) {
supportList.innerHTML = contactsData.contacts.map(contact => `
<div class="result-item" onclick="window.location.href='/contacts/${contact.id}'" style="cursor: pointer;">
<div>
<div class="fw-bold">${escapeHtml(contact.first_name)} ${escapeHtml(contact.last_name)}</div>
<div class="small text-muted">
<i class="bi bi-person me-1"></i>Kontakt
${contact.email ? `${contact.email}` : ''}
${contact.title ? `${contact.title}` : ''}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`).join('');
}
} else {
document.getElementById('supportResults').style.display = 'none';
}
} catch (e) {
console.log('Contacts search not available');
}
// Search emails
try {
const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
const emailsData = await emailsResponse.json();
if (Array.isArray(emailsData) && emailsData.length > 0) {
hasResults = true;
const emailResults = document.getElementById('emailResults');
if (emailResults) {
emailResults.style.display = 'block';
const emailList = emailResults.querySelector('.result-items');
if (emailList) {
emailList.innerHTML = emailsData.map(mail => {
const received = mail.received_date
? new Date(mail.received_date).toLocaleString('da-DK')
: '-';
const sender = mail.sender_name || mail.sender_email || '-';
const isUnread = !Boolean(mail.is_read);
return `
<div class="result-item" onclick="window.location.href='/emails?open=${mail.id}'" style="cursor: pointer;">
<div>
<div class="fw-bold">${escapeHtml(mail.subject || '(Ingen emne)')}</div>
<div class="small text-muted">
<i class="bi bi-envelope me-1"></i>${escapeHtml(sender)}
${mail.linked_case_id ? ` • Sag #${mail.linked_case_id}` : ''}
${isUnread ? ' • <span class="text-warning">Ulæst</span>' : ''}
${escapeHtml(received)}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`;
}).join('');
}
}
} else {
const emailResults = document.getElementById('emailResults');
if (emailResults) emailResults.style.display = 'none';
}
} catch (e) {
console.log('Email search not available');
const emailResults = document.getElementById('emailResults');
if (emailResults) emailResults.style.display = 'none';
}
// Search hardware
try {
const hardwareResponse = await fetch(`/api/v1/hardware?search=${encodeURIComponent(query)}&limit=5`);
const hardwareData = await hardwareResponse.json();
if (hardwareData.hardware && hardwareData.hardware.length > 0) {
hasResults = true;
const salesResults = document.getElementById('salesResults');
if (salesResults) {
salesResults.style.display = 'block';
const salesList = salesResults.querySelector('.result-items');
if (salesList) {
salesList.innerHTML = hardwareData.hardware.map(hw => `
<div class="result-item" onclick="window.location.href='/hardware/${hw.id}'" style="cursor: pointer;">
<div>
<div class="fw-bold">${escapeHtml(hw.serial_number || hw.name)}</div>
<div class="small text-muted">
<i class="bi bi-pc-display me-1"></i>Hardware
${hw.type ? `${hw.type}` : ''}
${hw.customer_name ? `${hw.customer_name}` : ''}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`).join('');
}
}
}
} catch (e) {
console.log('Hardware search not available');
}
// Search vendors
try {
const vendorsResponse = await fetch(`/api/v1/vendors?search=${encodeURIComponent(query)}&limit=5`);
const vendorsData = await vendorsResponse.json();
if (vendorsData.vendors && vendorsData.vendors.length > 0) {
hasResults = true;
const financeResults = document.getElementById('financeResults');
if (financeResults) {
financeResults.style.display = 'block';
const financeList = financeResults.querySelector('.result-items');
if (financeList) {
financeList.innerHTML = vendorsData.vendors.map(vendor => `
<div class="result-item" onclick="window.location.href='/vendors/${vendor.id}'" style="cursor: pointer;">
<div>
<div class="fw-bold">${escapeHtml(vendor.name)}</div>
<div class="small text-muted">
<i class="bi bi-cart me-1"></i>Leverandør
${vendor.cvr_number ? ` • CVR: ${vendor.cvr_number}` : ''}
${vendor.email ? `${vendor.email}` : ''}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`).join('');
}
}
}
} catch (e) {
console.log('Vendors search not available');
}
// Show empty state if no results
if (!hasResults) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('emptyState').innerHTML = `
<div class="text-center py-5">
<i class="bi bi-search" style="font-size: 3rem; opacity: 0.3;"></i>
<p class="text-muted mt-3">Ingen resultater for "${escapeHtml(query)}"</p>
</div>
`;
}
} catch (error) {
console.error('Search error:', error);
}
}
// Workflow definitions per entity type
const workflows = {
customer: [
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` },
{ label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) },
{ label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` }
],
contact: [
{ label: 'Ring op', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.mobile_phone || data.phone || 'Intet telefonnummer')) },
{ label: 'Send email', icon: 'envelope', action: (data) => window.location.href = `mailto:${data.email}` },
{ label: 'Opret møde', icon: 'calendar-event', action: (data) => alert('Opret møde funktionalitet kommer snart') },
{ label: 'Vis kontakt', icon: 'eye', action: (data) => window.location.href = `/contacts/${data.id}` }
],
vendor: [
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?vendor=${data.id}` },
{ label: 'Se produkter', icon: 'box-seam', action: (data) => window.location.href = `/vendors/${data.id}/products` },
{ label: 'Vis leverandør', icon: 'eye', action: (data) => window.location.href = `/vendors/${data.id}` }
],
invoice: [
{ label: 'Vis faktura', icon: 'eye', action: (data) => window.location.href = `/invoices/${data.id}` },
{ label: 'Udskriv faktura', icon: 'printer', action: (data) => window.print() },
{ label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') },
{ label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` }
],
rodekasse: [
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
{ label: 'Slet', icon: 'trash', action: (data) => confirm('Er du sikker?') && alert('Slet funktionalitet kommer snart') }
]
};
// Show contextual workflows based on entity
function showWorkflows(entityType, entityData) {
selectedEntity = entityData;
const workflowSection = document.getElementById('workflowActions');
const workflowButtons = document.getElementById('workflowButtons');
const entityWorkflows = workflows[entityType];
if (!entityWorkflows) {
workflowSection.style.display = 'none';
return;
}
workflowButtons.innerHTML = entityWorkflows.map(wf => `
<button class="btn btn-outline-primary" onclick="executeWorkflow('${entityType}', '${wf.label}')">
<i class="bi bi-${wf.icon} me-2"></i>${wf.label}
</button>
`).join('');
workflowSection.style.display = 'block';
}
// Execute workflow action
window.executeWorkflow = function(entityType, label) {
const workflow = workflows[entityType].find(w => w.label === label);
if (workflow && selectedEntity) {
workflow.action(selectedEntity);
}
};
// Search function already implemented in DOMContentLoaded above - duplicate removed
// Hover effects for result items
document.addEventListener('DOMContentLoaded', () => {
const style = document.createElement('style');
style.textContent = `
.result-item:hover {
border-color: var(--accent) !important;
background: var(--accent-light) !important;
}
`;
document.head.appendChild(style);
});
// Nested dropdown support - simple click-based approach that works reliably
document.addEventListener('DOMContentLoaded', () => {
// Find all submenu toggle links
document.querySelectorAll('.dropdown-submenu > a').forEach((toggle) => {
toggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Get the submenu
const submenu = this.nextElementSibling;
if (!submenu || !submenu.classList.contains('dropdown-menu')) return;
// Close all other submenus first
document.querySelectorAll('.dropdown-submenu .dropdown-menu').forEach((menu) => {
if (menu !== submenu) {
menu.classList.remove('show');
}
});
// Toggle this submenu
submenu.classList.toggle('show');
});
});
// Close all submenus when parent dropdown closes
document.querySelectorAll('.dropdown').forEach((dropdown) => {
dropdown.addEventListener('hide.bs.dropdown', () => {
dropdown.querySelectorAll('.dropdown-submenu .dropdown-menu').forEach((submenu) => {
submenu.classList.remove('show');
});
});
});
// Close submenu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.dropdown-submenu')) {
document.querySelectorAll('.dropdown-submenu .dropdown-menu.show').forEach((submenu) => {
submenu.classList.remove('show');
});
}
});
});
</script>
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
<!-- Manual Help Modal -->
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
<!-- Profile Modal -->
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content" style="border-radius: 16px;">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-person-circle me-2"></i>Profil</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" id="profileTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="profile-overview-tab" data-bs-toggle="tab" data-bs-target="#profile-overview" type="button" role="tab">
Overblik
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-reminders-tab" data-bs-toggle="tab" data-bs-target="#profile-reminders" type="button" role="tab">
Reminders
</button>
</li>
</ul>
<div class="tab-content" id="profileTabsContent">
<div class="tab-pane fade show active" id="profile-overview" role="tabpanel" tabindex="0">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Fuldt navn</label>
<input type="text" class="form-control" id="prof_full_name" placeholder="Dit navn">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Titel / rolle</label>
<input type="text" class="form-control" id="prof_title" placeholder="f.eks. Teknikker">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Mobilnummer</label>
<input type="tel" class="form-control" id="prof_phone" placeholder="f.eks. +45 12 34 56 78">
</div>
<div class="col-12">
<label class="form-label fw-semibold">
<i class="bi bi-display me-1" style="color:var(--accent)"></i>Mine AnyDesk IDs
</label>
<div class="form-text mb-2">Tilføj alle maskiner du bruger som teknikker — bruges til automatisk at genkende dig i remote sessions.</div>
<div id="prof-anydesk-chips" class="d-flex flex-wrap gap-2 mb-2"></div>
<div class="input-group" style="max-width:400px">
<input type="text" class="form-control font-monospace" id="prof_anydesk_new_id"
placeholder="AnyDesk ID (tal)" autocomplete="off"
onkeydown="if(event.key==='Enter'){event.preventDefault();addAnyDeskId()}">
<input type="text" class="form-control" id="prof_anydesk_new_label"
placeholder="Navn (valgfri, f.eks. Laptop)" style="max-width:160px"
onkeydown="if(event.key==='Enter'){event.preventDefault();addAnyDeskId()}">
<button class="btn btn-outline-primary" type="button" onclick="addAnyDeskId()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
<div class="col-12 pt-1">
<button class="btn btn-primary btn-sm" onclick="saveUserProfile()">
<i class="bi bi-check-lg me-1"></i>Gem profil
</button>
<span id="prof-save-status" class="ms-2 small text-success" style="display:none">Gemt ✓</span>
</div>
</div>
</div>
<div class="tab-pane fade" id="profile-reminders" role="tabpanel" tabindex="0">
<div class="row g-3">
<div class="col-lg-5">
<div class="card">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-sliders me-2"></i>Notifikationsindstillinger</h6>
</div>
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="pref_notify_frontend">
<label class="form-check-label" for="pref_notify_frontend">Popup</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="pref_notify_email">
<label class="form-check-label" for="pref_notify_email">Email</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="pref_notify_mattermost">
<label class="form-check-label" for="pref_notify_mattermost">Mattermost</label>
</div>
<div class="mb-3">
<label class="form-label">Email override</label>
<input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk">
</div>
<button class="btn btn-sm btn-primary" onclick="saveReminderPreferences()">Gem</button>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-bell me-2"></i>Dine reminders</h6>
<button class="btn btn-sm btn-outline-primary" onclick="loadProfileReminders()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush" id="profileRemindersList">
<div class="p-4 text-center text-muted">Indlæser reminders...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<script>
async function loadReminderPreferences() {
try {
const res = await fetch('/api/v1/users/me/notification-preferences', { credentials: 'include' });
if (!res.ok) return;
const prefs = await res.json();
document.getElementById('pref_notify_frontend').checked = !!prefs.notify_frontend;
document.getElementById('pref_notify_email').checked = !!prefs.notify_email;
document.getElementById('pref_notify_mattermost').checked = !!prefs.notify_mattermost;
document.getElementById('pref_email_override').value = prefs.email_override || '';
} catch (e) {
console.error('Failed to load reminder preferences', e);
}
}
async function saveReminderPreferences() {
const payload = {
notify_frontend: document.getElementById('pref_notify_frontend').checked,
notify_email: document.getElementById('pref_notify_email').checked,
notify_mattermost: document.getElementById('pref_notify_mattermost').checked,
email_override: document.getElementById('pref_email_override').value || null
};
try {
const res = await fetch('/api/v1/users/me/notification-preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke gemme indstillinger');
}
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function loadProfileReminders() {
const list = document.getElementById('profileRemindersList');
if (!list) return;
list.innerHTML = '<div class="p-4 text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Henter reminders...</div>';
try {
const res = await fetch('/api/v1/reminders/my', { credentials: 'include' });
if (!res.ok) {
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke hente reminders.</div>';
return;
}
const reminders = await res.json();
renderProfileReminders(reminders || []);
} catch (e) {
console.error('Failed to load reminders', e);
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke hente reminders.</div>';
}
}
function renderProfileReminders(reminders) {
const list = document.getElementById('profileRemindersList');
if (!list) return;
if (!reminders.length) {
list.innerHTML = '<div class="p-4 text-center text-muted">Ingen reminders fundet.</div>';
return;
}
list.innerHTML = reminders.map(reminder => {
const statusBadge = reminder.is_active
? '<span class="badge bg-success">Aktiv</span>'
: '<span class="badge bg-secondary">Inaktiv</span>';
return `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3">
<div class="fw-bold">${reminder.title}</div>
<div class="small text-muted">Sag #${reminder.sag_id} · ${reminder.case_title || '-'}</div>
<div class="small text-muted">${reminder.message || ''}</div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
${statusBadge}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" onclick="toggleReminderActive(${reminder.id}, ${reminder.is_active ? 'false' : 'true'})">
${reminder.is_active ? 'Pause' : 'Aktivér'}
</button>
<button class="btn btn-outline-danger" onclick="deleteProfileReminder(${reminder.id})">
Slet
</button>
</div>
</div>
</div>
</div>
`;
}).join('');
}
async function toggleReminderActive(reminderId, isActive) {
try {
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ is_active: isActive })
});
if (!res.ok) throw new Error('Kunne ikke opdatere reminder');
loadProfileReminders();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function deleteProfileReminder(reminderId) {
if (!confirm('Vil du slette denne reminder?')) return;
try {
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Kunne ikke slette reminder');
loadProfileReminders();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function loadUserProfile() {
try {
const res = await fetch('/api/v1/auth/me/profile', { credentials: 'include' });
if (!res.ok) return;
const p = await res.json();
document.getElementById('prof_full_name').value = p.full_name || '';
document.getElementById('prof_title').value = p.title || '';
document.getElementById('prof_phone').value = p.phone || '';
} catch (e) { console.error('Failed to load profile', e); }
loadAnyDeskChips();
}
async function loadAnyDeskChips() {
try {
const res = await fetch('/api/v1/auth/me/anydesk-ids', { credentials: 'include' });
if (!res.ok) return;
const { ids } = await res.json();
const box = document.getElementById('prof-anydesk-chips');
box.innerHTML = ids.length
? ids.map(entry => `
<span class="badge d-inline-flex align-items-center gap-1 fs-6 fw-normal"
style="background:rgba(15,76,117,0.1);color:#0f4c75;border:1px solid rgba(15,76,117,0.25);padding:.35rem .7rem;border-radius:6px">
<i class="bi bi-display" style="font-size:.8rem"></i>
<code style="font-size:.85rem;background:none;color:inherit">${entry.anydesk_id}</code>
${entry.label ? `<span style="opacity:.7;font-size:.8rem">— ${entry.label}</span>` : ''}
<button type="button" onclick="removeAnyDeskId(${entry.id})"
style="background:none;border:none;cursor:pointer;opacity:.6;padding:0 0 0 2px;line-height:1;color:inherit"
title="Fjern">&times;</button>
</span>`).join('')
: '<span class="text-secondary small">Ingen IDs tilføjet endnu</span>';
} catch(e) { /* silent */ }
}
async function addAnyDeskId() {
const id = document.getElementById('prof_anydesk_new_id').value.trim();
const label = document.getElementById('prof_anydesk_new_label').value.trim();
if (!id) return;
try {
const res = await fetch('/api/v1/auth/me/anydesk-ids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ anydesk_id: id, label: label || null })
});
if (!res.ok) { const e = await res.json(); alert(e.detail || 'Fejl'); return; }
document.getElementById('prof_anydesk_new_id').value = '';
document.getElementById('prof_anydesk_new_label').value = '';
loadAnyDeskChips();
} catch(e) { alert('Fejl: ' + e.message); }
}
async function removeAnyDeskId(entryId) {
try {
const res = await fetch(`/api/v1/auth/me/anydesk-ids/${entryId}`, {
method: 'DELETE', credentials: 'include'
});
if (!res.ok) throw new Error('Fejl');
loadAnyDeskChips();
} catch(e) { alert('Fejl: ' + e.message); }
}
async function saveUserProfile() {
const payload = {
full_name: document.getElementById('prof_full_name').value || null,
title: document.getElementById('prof_title').value || null,
phone: document.getElementById('prof_phone').value || null,
};
try {
const res = await fetch('/api/v1/auth/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error((await res.json()).detail || 'Fejl');
const statusEl = document.getElementById('prof-save-status');
statusEl.style.display = '';
setTimeout(() => { statusEl.style.display = 'none'; }, 3000);
} catch (e) { alert('Fejl: ' + e.message); }
}
document.addEventListener('DOMContentLoaded', () => {
const profileModalEl = document.getElementById('profileModal');
if (profileModalEl) {
profileModalEl.addEventListener('shown.bs.modal', () => {
loadReminderPreferences();
loadProfileReminders();
loadUserProfile();
});
}
});
</script>
<!-- SMS Modal -->
<div class="modal fade" id="smsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="smsModalTitle">Send SMS</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Modtager</label>
<input type="text" class="form-control" id="smsRecipientInput" readonly>
</div>
<div class="mb-3">
<label class="form-label">Afsender</label>
<input type="text" class="form-control" id="smsSenderInput" placeholder="Valgfrit">
</div>
<div class="mb-2">
<label class="form-label">Besked</label>
<textarea class="form-control" id="smsMessageInput" rows="4" maxlength="1530"></textarea>
<div class="d-flex justify-content-end mt-1">
<small class="text-muted" id="smsCharCounter">0/1530</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="smsSendBtn">Send</button>
</div>
</div>
</div>
</div>
<!-- Maintenance Mode Overlay -->
<div id="maintenance-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 9999; backdrop-filter: blur(5px);">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: white; max-width: 500px; padding: 2rem;">
<div style="font-size: 4rem; margin-bottom: 1rem;">🔧</div>
<h2 style="font-weight: 700; margin-bottom: 1rem;">System under vedligeholdelse</h2>
<p id="maintenance-message" style="font-size: 1.1rem; margin-bottom: 1.5rem; opacity: 0.9;">Systemet er midlertidigt utilgængeligt på grund af vedligeholdelse.</p>
<div id="maintenance-eta" style="font-size: 1rem; margin-bottom: 2rem; opacity: 0.8;"></div>
<div class="spinner-border text-light" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
<p style="margin-top: 1.5rem; font-size: 0.9rem; opacity: 0.7;">
Siden opdateres automatisk når systemet er klar igen.
</p>
</div>
</div>
<script>
// Check maintenance mode status
let maintenanceCheckInterval = null;
function checkMaintenanceMode() {
fetch('/api/v1/system/maintenance')
.then(response => {
if (!response.ok) {
// Silently ignore 404 - maintenance endpoint not implemented yet
return null;
}
return response.json();
})
.then(data => {
if (!data) return; // Skip if endpoint doesn't exist
const overlay = document.getElementById('maintenance-overlay');
const messageEl = document.getElementById('maintenance-message');
const etaEl = document.getElementById('maintenance-eta');
if (data.maintenance_mode) {
// Show overlay
overlay.style.display = 'block';
// Update message
if (data.maintenance_message) {
messageEl.textContent = data.maintenance_message;
}
// Update ETA
if (data.maintenance_eta_minutes) {
etaEl.textContent = `Estimeret tid: ${data.maintenance_eta_minutes} minutter`;
} else {
etaEl.textContent = '';
}
// Start polling every 5 seconds if not already polling
if (!maintenanceCheckInterval) {
maintenanceCheckInterval = setInterval(checkMaintenanceMode, 5000);
}
} else {
// Hide overlay
overlay.style.display = 'none';
// Stop polling if maintenance is over
if (maintenanceCheckInterval) {
clearInterval(maintenanceCheckInterval);
maintenanceCheckInterval = null;
}
}
})
.catch(error => {
// Silently ignore errors - maintenance check is not critical
});
}
// Check on page load (optional feature, don't block if not available)
checkMaintenanceMode();
// Check periodically (every 30 seconds when not in maintenance)
setInterval(() => {
if (!maintenanceCheckInterval) {
checkMaintenanceMode();
}
}, 30000);
// Global Logout Function
function logoutUser(event) {
if (event) event.preventDefault();
// Clear local storage
localStorage.removeItem('access_token');
localStorage.removeItem('user');
// Clear cookies
document.cookie = "access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
// Redirect to login
window.location.href = '/login';
}
</script>
{% block scripts %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>