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