2025-12-06 02:22:01 +01:00
<!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 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;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--border-radius: 12px;
}
[data-bs-theme="dark"] {
--bg-body: #212529;
--bg-card: #2c3034;
--text-primary: #f8f9fa;
--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;
}
.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);
}
2025-12-13 12:06:28 +01:00
/* 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;
}
2025-12-06 02:22:01 +01:00
.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);
}
2025-12-13 12:06:28 +01:00
.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);
}
2025-12-06 02:22:01 +01:00
< / 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 >
2025-12-06 11:04:19 +01:00
< li > < a class = "dropdown-item py-2" href = "/vendors" > Leverandører< / a > < / li >
2025-12-06 02:22:01 +01:00
< 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 >
2026-01-31 23:16:24 +01:00
< li class = "nav-item" >
2026-02-01 11:58:44 +01:00
< a class = "nav-link" href = "/sag" >
2026-01-31 23:16:24 +01:00
< i class = "bi bi-list-check me-2" > < / i > Sager
< / a >
< / li >
2025-12-06 02:22:01 +01:00
< 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" >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs.
- Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups.
- Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
2025-12-15 23:40:23 +01:00
< li > < a class = "dropdown-item py-2" href = "/ticket/dashboard" > < i class = "bi bi-speedometer2 me-2" > < / i > Dashboard< / a > < / li >
< li > < a class = "dropdown-item py-2" href = "/ticket/tickets" > < i class = "bi bi-ticket-detailed me-2" > < / i > Alle Tickets< / a > < / li >
< li > < a class = "dropdown-item py-2" href = "/ticket/worklog/review" > < i class = "bi bi-clock-history me-2" > < / i > Godkend Worklog< / a > < / li >
2026-01-11 19:23:21 +01:00
< li > < a class = "dropdown-item py-2" href = "/conversations/my" > < i class = "bi bi-mic me-2" > < / i > Mine Samtaler< / a > < / li >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs.
- Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups.
- Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
2025-12-15 23:40:23 +01:00
< li > < hr class = "dropdown-divider" > < / li >
2026-01-31 23:16:24 +01:00
< li > < a class = "dropdown-item py-2" href = "/hardware" > < i class = "bi bi-laptop me-2" > < / i > Hardware Assets< / 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 >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs.
- Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups.
- Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
2025-12-15 23:40:23 +01:00
< li > < a class = "dropdown-item py-2" href = "#" > Ny Ticket< / a > < / li >
2025-12-16 15:36:11 +01:00
< 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 >
2025-12-06 02:22:01 +01:00
< li > < hr class = "dropdown-divider" > < / li >
< li > < a class = "dropdown-item py-2" href = "#" > Knowledge Base< / 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< / a > < / li >
< li > < a class = "dropdown-item py-2" href = "#" > Produkter< / a > < / li >
< li > < hr class = "dropdown-divider" > < / li >
2026-01-25 03:29:28 +01:00
< 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 >
2026-01-28 07:48:10 +01:00
< li > < a class = "dropdown-item py-2" href = "/opportunities" > < i class = "bi bi-briefcase me-2" > < / i > Muligheder< / a > < / li >
2026-01-28 01:41:57 +01:00
< li > < a class = "dropdown-item py-2" href = "/pipeline" > < i class = "bi bi-diagram-3 me-2" > < / i > Pipeline< / a > < / li >
2025-12-06 02:22:01 +01:00
< / 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 >
2026-01-07 10:32:41 +01:00
< li > < a class = "dropdown-item py-2" href = "/billing/supplier-invoices" > < i class = "bi bi-receipt me-2" > < / i > Leverandør fakturaer< / a > < / li >
2025-12-06 02:22:01 +01:00
< 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 >
2025-12-13 12:06:28 +01:00
< li class = "dropdown-submenu" >
< a class = "dropdown-item dropdown-toggle py-2" href = "#" data-submenu-toggle = "timetracking" >
< span > < i class = "bi bi-clock-history me-2" > < / i > Timetracking< / span >
< i class = "bi bi-chevron-right small opacity-75" > < / i >
< / a >
< ul class = "dropdown-menu" data-submenu = "timetracking" >
< li > < a class = "dropdown-item py-2" href = "/timetracking" > < i class = "bi bi-speedometer2 me-2" > < / i > Dashboard< / a > < / li >
2026-01-10 01:37:08 +01:00
< li > < a class = "dropdown-item py-2" href = "/timetracking/registrations" > < i class = "bi bi-list-columns-reverse me-2" > < / i > Registreringer< / a > < / li >
2025-12-13 12:06:28 +01:00
< 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/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 >
< / li >
< li > < hr class = "dropdown-divider" > < / li >
2025-12-06 02:22:01 +01:00
< li > < a class = "dropdown-item py-2" href = "#" > Rapporter< / a > < / li >
< / ul >
< / li >
2025-12-11 12:45:29 +01:00
< li class = "nav-item" >
< a class = "nav-link" href = "/emails" >
< i class = "bi bi-envelope me-2" > < / i > Email
< / a >
< / li >
2025-12-06 02:22:01 +01:00
< / ul >
< div class = "d-flex align-items-center gap-3" >
< 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" style = "background: var(--accent-light); color: var(--accent);" > < 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" >
2026-02-06 10:47:14 +01:00
< li > < a class = "dropdown-item py-2" href = "#" data-bs-toggle = "modal" data-bs-target = "#profileModal" > Profil< / a > < / li >
2025-12-06 11:04:19 +01:00
< li > < a class = "dropdown-item py-2" href = "/settings" > < i class = "bi bi-gear me-2" > < / i > Indstillinger< / a > < / li >
2025-12-15 12:28:12 +01:00
< li > < a class = "dropdown-item py-2" href = "/backups" > < i class = "bi bi-hdd-stack me-2" > < / i > Backup System< / a > < / li >
2025-12-06 21:27:47 +01:00
< li > < a class = "dropdown-item py-2" href = "/devportal" > < i class = "bi bi-code-square me-2" > < / i > DEV Portal< / a > < / li >
2025-12-06 02:22:01 +01:00
< li > < hr class = "dropdown-divider" > < / li >
< li > < a class = "dropdown-item py-2 text-danger" href = "#" > 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;" >
2025-12-06 21:27:47 +01:00
<!-- Search Results & Workflows (3/4 width) -->
2025-12-06 02:22:01 +01:00
< div class = "col-lg-9 p-4" style = "border-right: 1px solid rgba(0,0,0,0.1);" >
2025-12-06 21:27:47 +01:00
<!-- 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 >
2025-12-06 02:22:01 +01:00
< 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 >
2025-12-06 21:27:47 +01:00
< p class = "text-muted mt-3" > Tryk < kbd > ⌘K< / kbd > eller begynd at skrive...< / p >
2025-12-06 02:22:01 +01:00
< / 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 = "/crm/workflow" 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 = "/support/workflow" 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 >
<!-- 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 = "/sales/workflow" 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 = "/finance/workflow" 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 >
2025-12-06 21:27:47 +01:00
<!-- 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 -->
2025-12-06 02:22:01 +01:00
< / div >
2025-12-06 21:27:47 +01:00
< / 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 >
2025-12-06 02:22:01 +01:00
< / div >
< / div >
2025-12-06 21:27:47 +01:00
< / 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 >
2025-12-06 02:22:01 +01:00
< / div >
< / div >
2025-12-06 21:27:47 +01:00
< / 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 >
2025-12-06 02:22:01 +01:00
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs.
- Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups.
- Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
2025-12-15 23:40:23 +01:00
{% block content_wrapper %}
2025-12-06 02:22:01 +01:00
< div class = "container-fluid px-4 py-4" >
{% block content %}{% endblock %}
< / div >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs.
- Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups.
- Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
2025-12-15 23:40:23 +01:00
{% endblock %}
2025-12-06 02:22:01 +01:00
< script src = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" > < / script >
2026-02-01 00:25:02 +01:00
< script src = "/static/js/tag-picker.js?v=2.0" > < / script >
2026-02-06 10:47:14 +01:00
< script src = "/static/js/notifications.js?v=1.0" > < / script >
2025-12-06 02:22:01 +01:00
< 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');
}
});
2025-12-08 09:15:52 +01:00
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
2025-12-13 12:06:28 +01:00
let selectedResultIndex = -1;
let allResults = [];
2025-12-08 09:15:52 +01:00
document.addEventListener('DOMContentLoaded', () => {
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
2025-12-13 12:06:28 +01:00
const globalSearchInput = document.getElementById('globalSearchInput');
// 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();
selectCurrentResult();
}
});
}
2025-12-06 02:22:01 +01:00
2025-12-08 09:15:52 +01:00
// Keyboard shortcut: Cmd+K or Ctrl+K
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) & & e.key === 'k') {
e.preventDefault();
console.log('Cmd+K pressed - opening search modal'); // Debug
searchModal.show();
setTimeout(() => {
2025-12-13 12:06:28 +01:00
if (globalSearchInput) {
globalSearchInput.focus();
}
2025-12-08 09:15:52 +01:00
loadLiveStats();
loadRecentActivity();
}, 300);
}
// ESC to close
if (e.key === 'Escape') {
searchModal.hide();
}
});
// Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
2025-12-13 12:06:28 +01:00
if (globalSearchInput) {
globalSearchInput.value = '';
}
2025-12-08 09:15:52 +01:00
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('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
});
2025-12-06 02:22:01 +01:00
});
2025-12-06 21:27:47 +01:00
// Load live statistics for the three boxes
async function loadLiveStats() {
try {
const response = await fetch('/api/v1/dashboard/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/dashboard/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';
}
2025-12-13 12:06:28 +01:00
// Helper function to escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
2025-12-06 21:27:47 +01:00
let selectedEntity = null;
2025-12-13 12:06:28 +01:00
// 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();
}
}
// Global search function
async function performGlobalSearch(query) {
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('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 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);
}
}
2025-12-06 21:27:47 +01:00
// 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: 'Opret sag', icon: 'ticket-detailed', action: (data) => window.location.href = `/tickets/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` }
],
ticket: [
{ label: 'Åbn sag', icon: 'folder2-open', action: (data) => window.location.href = `/tickets/${data.id}` },
{ label: 'Luk sag', icon: 'check-circle', action: (data) => alert('Luk sag funktionalitet kommer snart') },
{ label: 'Tildel medarbejder', icon: 'person-plus', action: (data) => alert('Tildel funktionalitet kommer snart') }
],
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);
}
};
2025-12-06 13:13:05 +01:00
2025-12-13 12:06:28 +01:00
// Search function already implemented in DOMContentLoaded above - duplicate removed
2025-12-06 02:22:01 +01:00
// 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);
});
2025-12-13 12:06:28 +01:00
// 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');
});
}
});
});
2025-12-06 02:22:01 +01:00
< / script >
2025-12-15 12:28:12 +01:00
2026-02-06 10:47:14 +01:00
<!-- 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 = "alert alert-info small mb-0" >
Profilinformation hentes fra din konto. Flere felter kan tilføjes her senere.
< / 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);
}
}
document.addEventListener('DOMContentLoaded', () => {
const profileModalEl = document.getElementById('profileModal');
if (profileModalEl) {
profileModalEl.addEventListener('shown.bs.modal', () => {
loadReminderPreferences();
loadProfileReminders();
});
}
});
< / script >
2025-12-15 12:28:12 +01:00
<!-- 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() {
2025-12-17 07:56:33 +01:00
fetch('/api/v1/system/maintenance')
2025-12-16 15:36:11 +01:00
.then(response => {
if (!response.ok) {
// Silently ignore 404 - maintenance endpoint not implemented yet
return null;
}
return response.json();
})
2025-12-15 12:28:12 +01:00
.then(data => {
2025-12-16 15:36:11 +01:00
if (!data) return; // Skip if endpoint doesn't exist
2025-12-15 12:28:12 +01:00
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 => {
2025-12-16 15:36:11 +01:00
// Silently ignore errors - maintenance check is not critical
2025-12-15 12:28:12 +01:00
});
}
2025-12-16 15:36:11 +01:00
// Check on page load (optional feature, don't block if not available)
2025-12-15 12:28:12 +01:00
checkMaintenanceMode();
// Check periodically (every 30 seconds when not in maintenance)
setInterval(() => {
if (!maintenanceCheckInterval) {
checkMaintenanceMode();
}
}, 30000);
< / script >
2025-12-06 02:22:01 +01:00
{% block extra_js %}{% endblock %}
< / body >
< / html >