- 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.
1112 lines
52 KiB
HTML
1112 lines
52 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 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);
|
|
}
|
|
|
|
/* 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="/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 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="/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>
|
|
<li><hr class="dropdown-divider"></li>
|
|
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
|
|
<li><a class="dropdown-item py-2" href="#">Klippekort</a></li>
|
|
<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>
|
|
<li><a class="dropdown-item py-2" href="#">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>Kassekladde</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 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>
|
|
<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>
|
|
<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">
|
|
<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">
|
|
<li><a class="dropdown-item py-2" href="#">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="/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="#">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="/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>
|
|
|
|
<!-- 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 %}
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></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 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();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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(() => {
|
|
if (globalSearchInput) {
|
|
globalSearchInput.focus();
|
|
}
|
|
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', () => {
|
|
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('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/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';
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
// 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>
|
|
|
|
<!-- 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/backups/maintenance')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
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 => {
|
|
console.error('Maintenance check error:', error);
|
|
});
|
|
}
|
|
|
|
// Check on page load
|
|
checkMaintenanceMode();
|
|
|
|
// Check periodically (every 30 seconds when not in maintenance)
|
|
setInterval(() => {
|
|
if (!maintenanceCheckInterval) {
|
|
checkMaintenanceMode();
|
|
}
|
|
}, 30000);
|
|
</script>
|
|
|
|
{% block extra_js %}{% endblock %}
|
|
</body>
|
|
</html> |