feat: Enhance case detail view with tab count badges and importance bubbles

This commit is contained in:
Christian 2026-04-23 23:42:31 +02:00
parent fcc7192015
commit ca6640c33c

View File

@ -1141,6 +1141,26 @@
box-shadow: 0 0 0 2px rgba(20, 28, 36, 0.95); box-shadow: 0 0 0 2px rgba(20, 28, 36, 0.95);
} }
.case-tab-count-badge {
display: none;
margin-left: 0.42rem;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.35rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
line-height: 1.25rem;
text-align: center;
background: color-mix(in srgb, var(--accent) 82%, #2f9e44);
color: #fff;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9);
}
[data-bs-theme="dark"] .case-tab-count-badge {
box-shadow: 0 0 0 2px rgba(20, 28, 36, 0.95);
}
[data-bs-theme="dark"] .narrative-description { [data-bs-theme="dark"] .narrative-description {
border-color: rgba(117, 194, 239, 0.24); border-color: rgba(117, 194, 239, 0.24);
background: linear-gradient(180deg, rgba(117, 194, 239, 0.14), rgba(117, 194, 239, 0.06)); background: linear-gradient(180deg, rgba(117, 194, 239, 0.14), rgba(117, 194, 239, 0.06));
@ -1776,10 +1796,13 @@
border-radius: 0; border-radius: 0;
padding: 0.55rem 0.45rem; padding: 0.55rem 0.45rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
width: 100%;
min-width: 0;
overflow-x: auto;
} }
.case-tabs-topbar.topbar-primary { .case-tabs-topbar.topbar-primary {
grid-template-columns: 105px minmax(260px, 1.45fr) minmax(150px, 0.95fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(240px, 1.35fr); grid-template-columns: repeat(8, minmax(130px, 1fr));
background: linear-gradient(140deg, rgba(15,76,117,0.08), rgba(15,76,117,0.01)); background: linear-gradient(140deg, rgba(15,76,117,0.08), rgba(15,76,117,0.01));
border: 1px solid rgba(15,76,117,0.22); border: 1px solid rgba(15,76,117,0.22);
border-radius: 0; border-radius: 0;
@ -1968,11 +1991,9 @@
.case-tabs-topbar.topbar-secondary { .case-tabs-topbar.topbar-secondary {
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */ /* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
grid-template-columns: grid-template-columns:
minmax(110px, 0.75fr) /* Type */
minmax(110px, 0.8fr) /* Prioritet */
minmax(105px, 0.75fr) /* Oprettet */ minmax(105px, 0.75fr) /* Oprettet */
minmax(195px, 1.3fr) /* Arbejdsstart (2 knapper) */ minmax(180px, 1.2fr) /* Arbejdsstart (2 knapper) */
minmax(195px, 1.3fr) /* Start senest (2 knapper) */ minmax(180px, 1.2fr) /* Start senest (2 knapper) */
minmax(150px, 1.1fr) /* Deadline (1 knap) */ minmax(150px, 1.1fr) /* Deadline (1 knap) */
minmax(120px, 0.85fr) /* AnyDesk */ minmax(120px, 0.85fr) /* AnyDesk */
minmax(140px, 1fr) /* Dokumenter */; minmax(140px, 1fr) /* Dokumenter */;
@ -2003,13 +2024,51 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--text-secondary); color: var(--text-secondary);
opacity: 0.75; opacity: 0.95;
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
} }
.case-tabs-topbar-label .field-importance-bubble {
margin-left: auto;
}
.field-importance-bubble {
width: 0.78rem;
height: 0.78rem;
border-radius: 999px;
flex: 0 0 auto;
border: 1px solid rgba(0,0,0,0.18);
background: rgba(148, 163, 184, 0.45);
box-shadow: 0 0 0 1px rgba(255,255,255,0.65), 0 0 0 0.5px rgba(0,0,0,0.12) inset;
}
.field-importance-bubble.sev-neutral {
background: rgba(148, 163, 184, 0.55);
border-color: rgba(100, 116, 139, 0.55);
}
.field-importance-bubble.sev-ok {
background: #22c55e;
border-color: #15803d;
}
.field-importance-bubble.sev-warn {
background: #f59e0b;
border-color: #b45309;
}
.field-importance-bubble.sev-critical {
background: #ef4444;
border-color: #991b1b;
}
[data-bs-theme="dark"] .field-importance-bubble {
box-shadow: 0 0 0 1px rgba(10, 17, 26, 0.55);
}
.case-tabs-topbar-value { .case-tabs-topbar-value {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
@ -2085,6 +2144,10 @@
padding: 0.45rem 0.5rem; padding: 0.45rem 0.5rem;
} }
.case-tabs-topbar .dropdown-menu {
z-index: 1080;
}
.topbar-secondary-inline { .topbar-secondary-inline {
display: flex; display: flex;
align-items: center; align-items: center;
@ -2379,11 +2442,24 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.case-detail-page-shell {
--case-topbar-offset: 0px;
margin-top: calc(3rem + var(--case-topbar-offset));
margin-bottom: 2rem;
position: relative;
}
@media (max-width: 992px) {
.case-detail-page-shell {
margin-top: calc(2.2rem + var(--case-topbar-offset));
}
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem;"> <div class="container-fluid case-detail-page-shell">
<div class="case-tabs-topbar topbar-primary" role="region" aria-label="Primær sagsbar"> <div class="case-tabs-topbar topbar-primary" role="region" aria-label="Primær sagsbar">
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item">
@ -2404,16 +2480,38 @@
</div> </div>
</div> </div>
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item field-status-item">
<div class="case-tabs-topbar-label"><i class="bi bi-circle-half"></i>Status</div> <div class="case-tabs-topbar-label"><i class="bi bi-circle-half"></i>Status <span class="field-importance-bubble {{ 'sev-ok' if (case.status or '')|lower in ['lukket', 'løst', 'closed', 'resolved'] else 'sev-warn' }}" data-field-bubble="status" aria-hidden="true"></span></div>
<select id="topbarStatusSelect" class="case-inline-select" onchange="saveCaseStatusFromTopbar()"> <select id="topbarStatusSelect" class="case-inline-select" onchange="saveCaseStatusFromTopbar()">
{% for st in status_options %} {% for st in status_options %}
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option> <option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item field-type">
<div class="case-tabs-topbar-label"><i class="bi bi-person-check"></i>Ansvarlig</div> <div class="case-tabs-topbar-label"><i class="bi bi-tag"></i>Type <span class="field-importance-bubble sev-ok" data-field-bubble="type" aria-hidden="true"></span></div>
<select id="topbarTypeSelect" class="case-inline-select" onchange="saveCaseTypeFromTopbar()">
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
<option value="opgave" {% if topbar_type == 'opgave' %}selected{% endif %}>Opgave</option>
<option value="ordre" {% if topbar_type == 'ordre' %}selected{% endif %}>Ordre</option>
<option value="projekt" {% if topbar_type == 'projekt' %}selected{% endif %}>Projekt</option>
<option value="service" {% if topbar_type == 'service' %}selected{% endif %}>Service</option>
</select>
</div>
<div class="case-tabs-topbar-item field-priority">
<div class="case-tabs-topbar-label"><i class="bi bi-flag"></i>Prioritet <span class="field-importance-bubble {{ 'sev-critical' if (case.priority or 'normal')|lower == 'urgent' else ('sev-warn' if (case.priority or 'normal')|lower == 'high' else 'sev-ok') }}" data-field-bubble="priority" aria-hidden="true"></span></div>
<select id="topbarPrioritySelect" class="case-inline-select" onchange="saveCasePriorityFromTopbar()">
{% set topbar_priority = (case.priority or 'normal')|lower %}
<option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option>
<option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option>
<option value="high" {% if topbar_priority == 'high' %}selected{% endif %}>Hoj</option>
<option value="urgent" {% if topbar_priority == 'urgent' %}selected{% endif %}>Akut</option>
</select>
</div>
<div class="case-tabs-topbar-item field-assignee-item">
<div class="case-tabs-topbar-label"><i class="bi bi-person-check"></i>Ansvarlig <span class="field-importance-bubble {{ 'sev-ok' if case.ansvarlig_bruger_id else 'sev-critical' }}" data-field-bubble="assignee" aria-hidden="true"></span></div>
<select id="tabsAssignmentUserSelect" class="case-inline-select" onchange="saveAssignmentFromTabsBar()"> <select id="tabsAssignmentUserSelect" class="case-inline-select" onchange="saveAssignmentFromTabsBar()">
<option value="">Ingen bruger</option> <option value="">Ingen bruger</option>
{% for user in assignment_users or [] %} {% for user in assignment_users or [] %}
@ -2421,8 +2519,8 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item field-group-item">
<div class="case-tabs-topbar-label"><i class="bi bi-people"></i>Gruppe</div> <div class="case-tabs-topbar-label"><i class="bi bi-people"></i>Gruppe <span class="field-importance-bubble {{ 'sev-ok' if case.assigned_group_id else 'sev-warn' }}" data-field-bubble="group" aria-hidden="true"></span></div>
<select id="tabsAssignmentGroupSelect" class="case-inline-select" onchange="saveAssignmentFromTabsBar()"> <select id="tabsAssignmentGroupSelect" class="case-inline-select" onchange="saveAssignmentFromTabsBar()">
<option value="">Ingen gruppe</option> <option value="">Ingen gruppe</option>
{% for group in assignment_groups or [] %} {% for group in assignment_groups or [] %}
@ -2430,8 +2528,8 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item field-next-item">
<div class="case-tabs-topbar-label"><i class="bi bi-list-check"></i>Næste</div> <div class="case-tabs-topbar-label"><i class="bi bi-list-check"></i>Næste <span class="field-importance-bubble sev-warn" data-field-bubble="next" aria-hidden="true"></span></div>
<div id="topbarNextTodoValue" class="case-tabs-topbar-value multiline">Henter næste todo...</div> <div id="topbarNextTodoValue" class="case-tabs-topbar-value multiline">Henter næste todo...</div>
<div id="topbarNextTodoMeta" class="topbar-next-meta">-</div> <div id="topbarNextTodoMeta" class="topbar-next-meta">-</div>
</div> </div>
@ -2445,34 +2543,12 @@
<div class="case-top-aux"> <div class="case-top-aux">
<div class="case-tabs-topbar topbar-secondary" role="region" aria-label="Sekundær sagsbar"> <div class="case-tabs-topbar topbar-secondary" role="region" aria-label="Sekundær sagsbar">
<div class="case-tabs-topbar-item field-type">
<div class="case-tabs-topbar-label"><i class="bi bi-tag"></i>Type</div>
<select id="topbarTypeSelect" class="case-inline-select" onchange="saveCaseTypeFromTopbar()">
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
<option value="opgave" {% if topbar_type == 'opgave' %}selected{% endif %}>Opgave</option>
<option value="ordre" {% if topbar_type == 'ordre' %}selected{% endif %}>Ordre</option>
<option value="projekt" {% if topbar_type == 'projekt' %}selected{% endif %}>Projekt</option>
<option value="service" {% if topbar_type == 'service' %}selected{% endif %}>Service</option>
</select>
</div>
<div class="case-tabs-topbar-item field-priority">
<div class="case-tabs-topbar-label"><i class="bi bi-flag"></i>Prioritet</div>
<select id="topbarPrioritySelect" class="case-inline-select" onchange="saveCasePriorityFromTopbar()">
{% set topbar_priority = (case.priority or 'normal')|lower %}
<option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option>
<option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option>
<option value="high" {% if topbar_priority == 'high' %}selected{% endif %}>Hoj</option>
<option value="urgent" {% if topbar_priority == 'urgent' %}selected{% endif %}>Akut</option>
</select>
</div>
<div class="case-tabs-topbar-item field-created"> <div class="case-tabs-topbar-item field-created">
<div class="case-tabs-topbar-label"><i class="bi bi-plus-circle"></i>Oprettelses dato</div> <div class="case-tabs-topbar-label"><i class="bi bi-plus-circle"></i>Oprettelses dato <span class="field-importance-bubble sev-ok" data-field-bubble="created" aria-hidden="true"></span></div>
<div class="case-tabs-topbar-value">{{ case.created_at.strftime('%d/%m/%Y') if case.created_at else '-' }}</div> <div class="case-tabs-topbar-value">{{ case.created_at.strftime('%d/%m/%Y') if case.created_at else '-' }}</div>
</div> </div>
<div class="case-tabs-topbar-item field-start"> <div class="case-tabs-topbar-item field-start">
<div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart</div> <div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart <span class="field-importance-bubble {{ 'sev-ok' if case.start_date else 'sev-warn' }}" data-field-bubble="start" aria-hidden="true"></span></div>
<div class="topbar-secondary-inline" style="gap: 0.25rem;"> <div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input <input
id="topbarStartDateInput" id="topbarStartDateInput"
@ -2501,7 +2577,7 @@
</div> </div>
</div> </div>
<div class="case-tabs-topbar-item field-start-before"> <div class="case-tabs-topbar-item field-start-before">
<div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div> <div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest <span class="field-importance-bubble {{ 'sev-warn' if case.deferred_until else 'sev-neutral' }}" data-field-bubble="start_before" aria-hidden="true"></span></div>
<div class="topbar-secondary-inline" style="gap: 0.25rem;"> <div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input <input
id="topbarDeferredInput" id="topbarDeferredInput"
@ -2545,7 +2621,7 @@
</div> </div>
</div> </div>
<div class="case-tabs-topbar-item field-deadline"> <div class="case-tabs-topbar-item field-deadline">
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div> <div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato <span class="field-importance-bubble {{ 'sev-warn' if case.deadline else 'sev-neutral' }}" data-field-bubble="deadline" aria-hidden="true"></span></div>
<div class="topbar-secondary-inline" style="gap: 0.25rem;"> <div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input <input
id="topbarDeadlineInput" id="topbarDeadlineInput"
@ -2789,11 +2865,13 @@
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab" onclick="forceCaseTabActivation('details', this)"> <button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab" onclick="forceCaseTabActivation('details', this)">
<i class="bi bi-card-text me-2"></i>Sagsdetaljer <i class="bi bi-card-text me-2"></i>Sagsdetaljer
<span class="case-tab-count-badge" id="detailsTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="solution-tab" data-bs-toggle="tab" data-bs-target="#solution" type="button" role="tab" data-module-tab="solution" onclick="forceCaseTabActivation('solution', this)"> <button class="nav-link" id="solution-tab" data-bs-toggle="tab" data-bs-target="#solution" type="button" role="tab" data-module-tab="solution" onclick="forceCaseTabActivation('solution', this)">
<i class="bi bi-lightbulb me-2"></i>Løsning <i class="bi bi-lightbulb me-2"></i>Løsning
<span class="case-tab-count-badge" id="solutionTabCountBadge"></span>
{% if solution %} {% if solution %}
<span class="badge bg-success ms-1 rounded-pill"><i class="bi bi-check"></i></span> <span class="badge bg-success ms-1 rounded-pill"><i class="bi bi-check"></i></span>
{% endif %} {% endif %}
@ -2802,32 +2880,38 @@
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="emails-tab" data-bs-toggle="tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails" onclick="forceCaseTabActivation('emails', this)"> <button class="nav-link" id="emails-tab" data-bs-toggle="tab" data-bs-target="#emails" type="button" role="tab" data-module-tab="emails" onclick="forceCaseTabActivation('emails', this)">
<i class="bi bi-envelope me-2"></i>E-mail <i class="bi bi-envelope me-2"></i>E-mail
<span class="case-tab-count-badge" id="emailsTabCountBadge"></span>
<span id="emailTabUnreadBadge" class="email-tab-unread-badge" aria-label="Ulæste emails"></span> <span id="emailTabUnreadBadge" class="email-tab-unread-badge" aria-label="Ulæste emails"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="sales-tab" data-bs-toggle="tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales" onclick="forceCaseTabActivation('sales', this)"> <button class="nav-link" id="sales-tab" data-bs-toggle="tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales" onclick="forceCaseTabActivation('sales', this)">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg <i class="bi bi-basket3 me-2"></i>Varekøb & Salg
<span class="case-tab-count-badge" id="salesTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="supplier-tab" data-bs-toggle="tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="forceCaseTabActivation('supplier', this)"> <button class="nav-link" id="supplier-tab" data-bs-toggle="tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="forceCaseTabActivation('supplier', this)">
<i class="bi bi-receipt-cutoff me-2"></i>Leverandør <i class="bi bi-receipt-cutoff me-2"></i>Leverandør
<span class="case-tab-count-badge" id="supplierTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)"> <button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)">
<i class="bi bi-clock-history me-2"></i>Tidsforbrug <i class="bi bi-clock-history me-2"></i>Tidsforbrug
<span class="case-tab-count-badge" id="timetrackingTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription" onclick="forceCaseTabActivation('subscription', this)"> <button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription" onclick="forceCaseTabActivation('subscription', this)">
<i class="bi bi-repeat me-2"></i>Abonnement <i class="bi bi-repeat me-2"></i>Abonnement
<span class="case-tab-count-badge" id="subscriptionTabCountBadge"></span>
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders" onclick="forceCaseTabActivation('reminders', this)"> <button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders" onclick="forceCaseTabActivation('reminders', this)">
<i class="bi bi-bell me-2"></i>Påmindelser <i class="bi bi-bell me-2"></i>Påmindelser
<span class="case-tab-count-badge" id="remindersTabCountBadge"></span>
</button> </button>
</li> </li>
</ul> </ul>
@ -3757,6 +3841,7 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
hydrateTopbarStatusOptions(); hydrateTopbarStatusOptions();
loadCaseCustomerTopAlerts(); loadCaseCustomerTopAlerts();
applyTopbarImportanceBubbles();
// Initialize modals // Initialize modals
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal')); contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal')); customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
@ -3813,6 +3898,36 @@
todoForm.addEventListener('submit', createTodoStep); todoForm.addEventListener('submit', createTodoStep);
} }
['topbarStatusSelect', 'tabsAssignmentUserSelect', 'tabsAssignmentGroupSelect', 'topbarTypeSelect', 'topbarPrioritySelect', 'topbarStartDateInput', 'topbarDeferredInput', 'topbarDeadlineInput'].forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', applyTopbarImportanceBubbles);
el.addEventListener('input', applyTopbarImportanceBubbles);
}
});
const caseTabsContent = document.getElementById('caseTabsContent');
if (caseTabsContent && typeof MutationObserver !== 'undefined') {
let tabBadgeTimer = null;
const scheduleBadgeRefresh = () => {
if (tabBadgeTimer) {
clearTimeout(tabBadgeTimer);
}
tabBadgeTimer = setTimeout(() => updateCaseTabCountBadges(), 80);
};
const tabObserver = new MutationObserver(scheduleBadgeRefresh);
tabObserver.observe(caseTabsContent, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['data-has-content', 'class']
});
}
updateCaseTabCountBadges();
const caseTabs = document.getElementById('caseTabs'); const caseTabs = document.getElementById('caseTabs');
if (caseTabs) { if (caseTabs) {
caseTabs.addEventListener('shown.bs.tab', async (event) => { caseTabs.addEventListener('shown.bs.tab', async (event) => {
@ -4917,10 +5032,42 @@
if (!moduleContainer) return; if (!moduleContainer) return;
try { try {
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`); let tags = [];
if (!response.ok) throw new Error('Kunne ikke hente tags'); let usingLegacyCaseTags = false;
const normalizeLegacyTags = (legacyTags) => (
Array.isArray(legacyTags)
? legacyTags.map((row) => ({
id: row.id || row.tag_id,
name: row.tag_navn || row.name || 'Tag',
color: row.color || '#0f4c75',
icon: row.icon || 'bi-tag'
}))
: []
);
const loadLegacyTags = async () => {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags`, { credentials: 'include' });
if (!legacyResponse.ok) {
throw new Error('Kunne ikke hente tags');
}
const legacyTags = await legacyResponse.json();
usingLegacyCaseTags = true;
return normalizeLegacyTags(legacyTags);
};
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`);
if (response.ok) {
const genericTags = await response.json();
tags = Array.isArray(genericTags) ? genericTags : [];
// Some hubs still store case tags via legacy sag endpoint.
if (tags.length === 0) {
tags = await loadLegacyTags();
}
} else {
tags = await loadLegacyTags();
}
const tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) { if (!Array.isArray(tags) || tags.length === 0) {
moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paaa sagen endnu</div>'; moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paaa sagen endnu</div>';
setModuleContentState('tags', false); setModuleContentState('tags', false);
@ -4936,6 +5083,13 @@
</span> </span>
`).join(''); `).join('');
if (usingLegacyCaseTags) {
moduleContainer.insertAdjacentHTML(
'beforeend',
'<div class="small text-muted mt-2">Viser tags via sag-endpoint</div>'
);
}
setModuleContentState('tags', true); setModuleContentState('tags', true);
} catch (error) { } catch (error) {
console.error('Error loading case tags module:', error); console.error('Error loading case tags module:', error);
@ -5003,12 +5157,32 @@
} }
async function removeCaseTagAndSync(tagId) { async function removeCaseTagAndSync(tagId) {
await window.removeEntityTag('case', caseId, tagId, 'case-tags-module'); try {
if (window.removeEntityTag) {
await window.removeEntityTag('case', caseId, tagId, 'case-tags-module');
} else {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags/${tagId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!legacyResponse.ok) {
throw new Error('Kunne ikke slette tag');
}
}
} catch (error) {
const legacyResponse = await fetch(`/api/v1/sag/${caseId}/tags/${tagId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!legacyResponse.ok) {
throw error;
}
}
await syncCaseTagsUi(); await syncCaseTagsUi();
} }
async function syncCaseTagsUi() { async function syncCaseTagsUi() {
if (window.renderEntityTags) { if (window.renderEntityTags && document.getElementById('case-tags')) {
await window.renderEntityTags('case', caseId, 'case-tags'); await window.renderEntityTags('case', caseId, 'case-tags');
} }
await loadCaseTagsModule(); await loadCaseTagsModule();
@ -5186,6 +5360,7 @@
valueEl.textContent = 'Ingen åbne todo-opgaver'; valueEl.textContent = 'Ingen åbne todo-opgaver';
metaEl.textContent = 'Alt er færdigt'; metaEl.textContent = 'Alt er færdigt';
setNextTodoOverrideId(null); setNextTodoOverrideId(null);
applyTopbarImportanceBubbles();
return; return;
} }
@ -5201,6 +5376,104 @@
metaEl.textContent = nextStep.due_date metaEl.textContent = nextStep.due_date
? `Forfald: ${formatTodoDate(nextStep.due_date)}` ? `Forfald: ${formatTodoDate(nextStep.due_date)}`
: 'Ingen forfaldsdato'; : 'Ingen forfaldsdato';
applyTopbarImportanceBubbles();
}
function parseIsoDateToDay(value) {
const str = String(value || '').trim();
if (!str) return null;
const d = new Date(str + 'T00:00:00');
if (Number.isNaN(d.getTime())) return null;
return d;
}
function dayDiffFromToday(dateObj) {
if (!dateObj) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((dateObj.getTime() - today.getTime()) / 86400000);
}
function bubbleSeverityFromDate(rawValue, opts = {}) {
const dateObj = parseIsoDateToDay(rawValue);
if (!dateObj) return opts.empty || 'sev-neutral';
const diff = dayDiffFromToday(dateObj);
const warnDays = Number(opts.warnDays || 2);
if (diff < 0) return 'sev-critical';
if (diff <= warnDays) return 'sev-warn';
return 'sev-ok';
}
function applyTopbarImportanceBubbles() {
const statusValue = String(document.getElementById('topbarStatusSelect')?.value || '').toLowerCase();
const assigneeValue = String(document.getElementById('tabsAssignmentUserSelect')?.value || '').trim();
const groupValue = String(document.getElementById('tabsAssignmentGroupSelect')?.value || '').trim();
const typeValue = String(document.getElementById('topbarTypeSelect')?.value || '').toLowerCase();
const priorityValue = String(document.getElementById('topbarPrioritySelect')?.value || '').toLowerCase();
const startValue = String(document.getElementById('topbarStartDateInput')?.value || '').trim();
const startBeforeValue = String(document.getElementById('topbarDeferredInput')?.value || '').trim();
const deadlineValue = String(document.getElementById('topbarDeadlineInput')?.value || '').trim();
const nextValue = String(document.getElementById('topbarNextTodoValue')?.textContent || '').trim().toLowerCase();
const nextMeta = String(document.getElementById('topbarNextTodoMeta')?.textContent || '').trim().toLowerCase();
const createdText = String(document.querySelector('.field-created .case-tabs-topbar-value')?.textContent || '').trim();
let createdSeverity = 'sev-neutral';
const createdMatch = createdText.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (createdMatch) {
const createdDate = new Date(Number(createdMatch[3]), Number(createdMatch[2]) - 1, Number(createdMatch[1]));
const age = Math.abs(dayDiffFromToday(createdDate));
if (age > 120) createdSeverity = 'sev-critical';
else if (age > 45) createdSeverity = 'sev-warn';
else createdSeverity = 'sev-ok';
}
let statusSeverity = 'sev-neutral';
if (!statusValue) statusSeverity = 'sev-critical';
else if (['lukket', 'løst', 'closed', 'resolved'].includes(statusValue)) statusSeverity = 'sev-ok';
else if (['afventer', 'under behandling'].includes(statusValue)) statusSeverity = 'sev-warn';
else statusSeverity = 'sev-warn';
let nextSeverity = 'sev-neutral';
if (nextValue.includes('henter')) {
nextSeverity = 'sev-neutral';
} else if (nextValue.includes('ingen åbne')) {
nextSeverity = 'sev-warn';
} else if (nextMeta.includes('forfald:')) {
const metaDateMatch = nextMeta.match(/(\d{2})[./-](\d{2})[./-](\d{4})/);
if (metaDateMatch) {
const due = new Date(Number(metaDateMatch[3]), Number(metaDateMatch[2]) - 1, Number(metaDateMatch[1]));
const diff = dayDiffFromToday(due);
if (diff < 0) nextSeverity = 'sev-critical';
else if (diff <= 2) nextSeverity = 'sev-warn';
else nextSeverity = 'sev-ok';
} else {
nextSeverity = 'sev-warn';
}
} else {
nextSeverity = 'sev-ok';
}
const fieldSeverity = {
status: statusSeverity,
assignee: assigneeValue ? 'sev-ok' : 'sev-critical',
group: groupValue ? 'sev-ok' : 'sev-warn',
next: nextSeverity,
type: typeValue ? 'sev-ok' : 'sev-neutral',
priority: priorityValue === 'urgent' ? 'sev-critical' : (priorityValue === 'high' ? 'sev-warn' : 'sev-ok'),
created: createdSeverity,
start: bubbleSeverityFromDate(startValue, { empty: 'sev-warn', warnDays: 0 }),
start_before: bubbleSeverityFromDate(startBeforeValue, { empty: 'sev-neutral', warnDays: 2 }),
deadline: bubbleSeverityFromDate(deadlineValue, { empty: 'sev-neutral', warnDays: 2 })
};
document.querySelectorAll('[data-field-bubble]').forEach((bubble) => {
const key = String(bubble.getAttribute('data-field-bubble') || '').trim();
const sev = fieldSeverity[key] || 'sev-neutral';
bubble.classList.remove('sev-neutral', 'sev-ok', 'sev-warn', 'sev-critical');
bubble.classList.add(sev);
});
} }
async function setNextTodoStep(stepId, isNext) { async function setNextTodoStep(stepId, isNext) {
@ -10201,10 +10474,15 @@
return; return;
} }
if (!caseAnyDeskModal) { const modalEl = document.getElementById('caseAnyDeskModal');
const modalEl = document.getElementById('caseAnyDeskModal'); if (!modalEl || typeof bootstrap === 'undefined' || !bootstrap.Modal) return;
if (!modalEl) return;
caseAnyDeskModal = new bootstrap.Modal(modalEl); if (!caseAnyDeskModal || typeof caseAnyDeskModal.show !== 'function') {
if (typeof bootstrap.Modal.getOrCreateInstance === 'function') {
caseAnyDeskModal = bootstrap.Modal.getOrCreateInstance(modalEl);
} else {
caseAnyDeskModal = new bootstrap.Modal(modalEl);
}
} }
const noteInput = document.getElementById('caseAnydeskNoteInput'); const noteInput = document.getElementById('caseAnydeskNoteInput');
@ -10241,7 +10519,9 @@
} }
} }
caseAnyDeskModal.show(); if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
caseAnyDeskModal.show();
}
} }
async function registerCaseAnyDeskSession() { async function registerCaseAnyDeskSession() {
@ -10301,7 +10581,9 @@
} }
const result = await res.json(); const result = await res.json();
if (caseAnyDeskModal) caseAnyDeskModal.hide(); if (caseAnyDeskModal && typeof caseAnyDeskModal.hide === 'function') {
caseAnyDeskModal.hide();
}
if (result?.deep_link) { if (result?.deep_link) {
window.location.href = result.deep_link; window.location.href = result.deep_link;
@ -10325,6 +10607,54 @@
} }
} }
// Ensure inline handlers and fallback listeners can always resolve AnyDesk actions.
window.openCaseAnyDeskModal = openCaseAnyDeskModal;
window.registerCaseAnyDeskSession = registerCaseAnyDeskSession;
window.onCaseAnyDeskIdInputChange = onCaseAnyDeskIdInputChange;
window.setCaseAnyDeskInputFromSaved = setCaseAnyDeskInputFromSaved;
document.addEventListener('DOMContentLoaded', () => {
const adjustCaseTopbarOffset = () => {
const shell = document.querySelector('.case-detail-page-shell');
const nav = document.querySelector('.navbar.fixed-top');
if (!shell || !nav) return;
const navHeight = Math.ceil(nav.getBoundingClientRect().height || 0);
const bodyPaddingTop = parseFloat(getComputedStyle(document.body).paddingTop || '0') || 0;
const needed = Math.max(0, navHeight - bodyPaddingTop + 8);
shell.style.setProperty('--case-topbar-offset', `${needed}px`);
};
adjustCaseTopbarOffset();
window.addEventListener('resize', adjustCaseTopbarOffset);
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
if (openBtn) {
openBtn.removeAttribute('onclick');
openBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openCaseAnyDeskModal();
});
}
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
if (connectBtn) {
connectBtn.removeAttribute('onclick');
connectBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
registerCaseAnyDeskSession();
});
}
const anydeskInput = document.getElementById('caseAnydeskIdInput');
if (anydeskInput) {
anydeskInput.removeAttribute('oninput');
anydeskInput.addEventListener('input', onCaseAnyDeskIdInputChange);
}
});
async function openCaseModuleAddPanel() { async function openCaseModuleAddPanel() {
if (typeof loadModulePrefs === 'function') { if (typeof loadModulePrefs === 'function') {
await loadModulePrefs(); await loadModulePrefs();
@ -11091,6 +11421,8 @@
if (initialValue) { if (initialValue) {
select.value = Array.from(known.values()).find((v) => v.toLowerCase() === initialValue.toLowerCase()) || initialValue; select.value = Array.from(known.values()).find((v) => v.toLowerCase() === initialValue.toLowerCase()) || initialValue;
} }
applyTopbarImportanceBubbles();
} }
function saveCaseTypeFromTopbar() { function saveCaseTypeFromTopbar() {
@ -11368,6 +11700,7 @@
if (!el) return; if (!el) return;
el.setAttribute('data-has-content', hasContent ? 'true' : 'false'); el.setAttribute('data-has-content', hasContent ? 'true' : 'false');
applyViewLayout(currentCaseView); applyViewLayout(currentCaseView);
updateCaseTabCountBadges();
} }
function applyViewLayout(viewName) { function applyViewLayout(viewName) {
@ -11432,6 +11765,13 @@
return; return;
} }
// Moduler med indhold skal altid vises.
if (hasContent) {
setVisibility(true);
el.classList.remove('module-empty-compact');
return;
}
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content. // HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
if (pref === false) { if (pref === false) {
setVisibility(false); setVisibility(false);
@ -11446,13 +11786,6 @@
return; return;
} }
// Default logic (ingen brugervalg) - har den content, så vis den
if (hasContent) {
setVisibility(true);
el.classList.remove('module-empty-compact');
return;
}
// Default logic - ingen content: se på layout defaults // Default logic - ingen content: se på layout defaults
if (standardModuleSet.has(moduleName)) { if (standardModuleSet.has(moduleName)) {
setVisibility(true); setVisibility(true);
@ -11510,6 +11843,63 @@
} }
} }
function _setCaseTabCountBadge(badgeId, count) {
const badge = document.getElementById(badgeId);
if (!badge) return;
const safeCount = Number(count || 0);
if (safeCount <= 0) {
badge.style.display = 'none';
badge.textContent = '';
return;
}
badge.style.display = 'inline-block';
badge.textContent = safeCount > 99 ? '99+' : String(safeCount);
}
function _countRows(selector) {
const container = document.querySelector(selector);
if (!container) return 0;
const rows = Array.from(container.querySelectorAll('tr'));
return rows.filter((row) => {
const cells = row.querySelectorAll('td');
if (cells.length === 0) return false;
const colspanCell = row.querySelector('td[colspan]');
if (colspanCell && cells.length === 1) return false;
return true;
}).length;
}
function updateCaseTabCountBadges() {
const detailCount = document.querySelectorAll('#details .right-module-card[data-has-content="true"]').length;
_setCaseTabCountBadge('detailsTabCountBadge', detailCount);
const solutionHasContent = String(document.getElementById('solution')?.getAttribute('data-has-content') || '').toLowerCase() === 'true';
_setCaseTabCountBadge('solutionTabCountBadge', solutionHasContent ? 1 : 0);
const emailThreads = Number(document.getElementById('linkedEmailThreadsCount')?.textContent || 0);
_setCaseTabCountBadge('emailsTabCountBadge', emailThreads);
const salesCount = _countRows('#saleItemsSalesBody') + _countRows('#saleItemsPurchaseBody');
_setCaseTabCountBadge('salesTabCountBadge', salesCount);
const supplierCount = _countRows('#supplierPurchaseLinesBody');
_setCaseTabCountBadge('supplierTabCountBadge', supplierCount);
const timeEntriesStore = (typeof timeV1EntriesById !== 'undefined' && timeV1EntriesById && typeof timeV1EntriesById === 'object')
? timeV1EntriesById
: null;
const timeCount = timeEntriesStore
? Object.keys(timeEntriesStore).length
: document.querySelectorAll('#timetracking tbody tr').length;
_setCaseTabCountBadge('timetrackingTabCountBadge', timeCount);
const subscriptionCount = _countRows('#subscriptionItemsBody');
_setCaseTabCountBadge('subscriptionTabCountBadge', subscriptionCount);
const reminderCount = document.querySelectorAll('#remindersList .list-group-item').length;
_setCaseTabCountBadge('remindersTabCountBadge', reminderCount);
}
async function applyViewFromTags() { async function applyViewFromTags() {
try { try {
const res = await fetch(`/api/v1/tags/entity/case/${caseIds}`); const res = await fetch(`/api/v1/tags/entity/case/${caseIds}`);
@ -11517,6 +11907,7 @@
const tags = await res.json(); const tags = await res.json();
const viewTag = tags.find(t => ['Pipeline', 'Kundevisning', 'Sag-detalje'].includes(t.name)); const viewTag = tags.find(t => ['Pipeline', 'Kundevisning', 'Sag-detalje'].includes(t.name));
applyViewLayout(viewTag ? viewTag.name : 'Sag-detalje'); applyViewLayout(viewTag ? viewTag.name : 'Sag-detalje');
updateCaseTabCountBadges();
} catch (e) { } catch (e) {
console.error('View tag lookup failed', e); console.error('View tag lookup failed', e);
} }
@ -11532,6 +11923,7 @@
return acc; return acc;
}, {}); }, {});
modulePrefs.time = true; modulePrefs.time = true;
updateCaseTabCountBadges();
} catch (e) { } catch (e) {
console.error('Module prefs load failed', e); console.error('Module prefs load failed', e);
} }