feat: Enhance case detail view with tab count badges and importance bubbles
This commit is contained in:
parent
fcc7192015
commit
ca6640c33c
@ -1141,6 +1141,26 @@
|
||||
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 {
|
||||
border-color: rgba(117, 194, 239, 0.24);
|
||||
background: linear-gradient(180deg, rgba(117, 194, 239, 0.14), rgba(117, 194, 239, 0.06));
|
||||
@ -1776,10 +1796,13 @@
|
||||
border-radius: 0;
|
||||
padding: 0.55rem 0.45rem;
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.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));
|
||||
border: 1px solid rgba(15,76,117,0.22);
|
||||
border-radius: 0;
|
||||
@ -1968,11 +1991,9 @@
|
||||
.case-tabs-topbar.topbar-secondary {
|
||||
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
|
||||
grid-template-columns:
|
||||
minmax(110px, 0.75fr) /* Type */
|
||||
minmax(110px, 0.8fr) /* Prioritet */
|
||||
minmax(105px, 0.75fr) /* Oprettet */
|
||||
minmax(195px, 1.3fr) /* Arbejdsstart (2 knapper) */
|
||||
minmax(195px, 1.3fr) /* Start senest (2 knapper) */
|
||||
minmax(180px, 1.2fr) /* Arbejdsstart (2 knapper) */
|
||||
minmax(180px, 1.2fr) /* Start senest (2 knapper) */
|
||||
minmax(150px, 1.1fr) /* Deadline (1 knap) */
|
||||
minmax(120px, 0.85fr) /* AnyDesk */
|
||||
minmax(140px, 1fr) /* Dokumenter */;
|
||||
@ -2003,13 +2024,51 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
opacity: 0.95;
|
||||
margin-bottom: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
@ -2085,6 +2144,10 @@
|
||||
padding: 0.45rem 0.5rem;
|
||||
}
|
||||
|
||||
.case-tabs-topbar .dropdown-menu {
|
||||
z-index: 1080;
|
||||
}
|
||||
|
||||
.topbar-secondary-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -2379,11 +2442,24 @@
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
{% 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-item">
|
||||
@ -2404,16 +2480,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-circle-half"></i>Status</div>
|
||||
<div class="case-tabs-topbar-item field-status-item">
|
||||
<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()">
|
||||
{% for st in status_options %}
|
||||
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-person-check"></i>Ansvarlig</div>
|
||||
<div class="case-tabs-topbar-item field-type">
|
||||
<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()">
|
||||
<option value="">Ingen bruger</option>
|
||||
{% for user in assignment_users or [] %}
|
||||
@ -2421,8 +2519,8 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-people"></i>Gruppe</div>
|
||||
<div class="case-tabs-topbar-item field-group-item">
|
||||
<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()">
|
||||
<option value="">Ingen gruppe</option>
|
||||
{% for group in assignment_groups or [] %}
|
||||
@ -2430,8 +2528,8 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="case-tabs-topbar-item">
|
||||
<div class="case-tabs-topbar-label"><i class="bi bi-list-check"></i>Næste</div>
|
||||
<div class="case-tabs-topbar-item field-next-item">
|
||||
<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="topbarNextTodoMeta" class="topbar-next-meta">-</div>
|
||||
</div>
|
||||
@ -2445,34 +2543,12 @@
|
||||
|
||||
<div class="case-top-aux">
|
||||
<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-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>
|
||||
<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;">
|
||||
<input
|
||||
id="topbarStartDateInput"
|
||||
@ -2501,7 +2577,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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;">
|
||||
<input
|
||||
id="topbarDeferredInput"
|
||||
@ -2545,7 +2621,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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;">
|
||||
<input
|
||||
id="topbarDeadlineInput"
|
||||
@ -2789,11 +2865,13 @@
|
||||
<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)">
|
||||
<i class="bi bi-card-text me-2"></i>Sagsdetaljer
|
||||
<span class="case-tab-count-badge" id="detailsTabCountBadge"></span>
|
||||
</button>
|
||||
</li>
|
||||
<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)">
|
||||
<i class="bi bi-lightbulb me-2"></i>Løsning
|
||||
<span class="case-tab-count-badge" id="solutionTabCountBadge"></span>
|
||||
{% if solution %}
|
||||
<span class="badge bg-success ms-1 rounded-pill"><i class="bi bi-check"></i></span>
|
||||
{% endif %}
|
||||
@ -2802,32 +2880,38 @@
|
||||
<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)">
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
<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)">
|
||||
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
||||
<span class="case-tab-count-badge" id="salesTabCountBadge"></span>
|
||||
</button>
|
||||
</li>
|
||||
<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)">
|
||||
<i class="bi bi-receipt-cutoff me-2"></i>Leverandør
|
||||
<span class="case-tab-count-badge" id="supplierTabCountBadge"></span>
|
||||
</button>
|
||||
</li>
|
||||
<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)">
|
||||
<i class="bi bi-clock-history me-2"></i>Tidsforbrug
|
||||
<span class="case-tab-count-badge" id="timetrackingTabCountBadge"></span>
|
||||
</button>
|
||||
</li>
|
||||
<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)">
|
||||
<i class="bi bi-repeat me-2"></i>Abonnement
|
||||
<span class="case-tab-count-badge" id="subscriptionTabCountBadge"></span>
|
||||
</button>
|
||||
</li>
|
||||
<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)">
|
||||
<i class="bi bi-bell me-2"></i>Påmindelser
|
||||
<span class="case-tab-count-badge" id="remindersTabCountBadge"></span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@ -3757,6 +3841,7 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
hydrateTopbarStatusOptions();
|
||||
loadCaseCustomerTopAlerts();
|
||||
applyTopbarImportanceBubbles();
|
||||
// Initialize modals
|
||||
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
|
||||
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
|
||||
@ -3813,6 +3898,36 @@
|
||||
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');
|
||||
if (caseTabs) {
|
||||
caseTabs.addEventListener('shown.bs.tab', async (event) => {
|
||||
@ -4917,10 +5032,42 @@
|
||||
if (!moduleContainer) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente tags');
|
||||
let 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) {
|
||||
moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paaa sagen endnu</div>';
|
||||
setModuleContentState('tags', false);
|
||||
@ -4936,6 +5083,13 @@
|
||||
</span>
|
||||
`).join('');
|
||||
|
||||
if (usingLegacyCaseTags) {
|
||||
moduleContainer.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<div class="small text-muted mt-2">Viser tags via sag-endpoint</div>'
|
||||
);
|
||||
}
|
||||
|
||||
setModuleContentState('tags', true);
|
||||
} catch (error) {
|
||||
console.error('Error loading case tags module:', error);
|
||||
@ -5003,12 +5157,32 @@
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function syncCaseTagsUi() {
|
||||
if (window.renderEntityTags) {
|
||||
if (window.renderEntityTags && document.getElementById('case-tags')) {
|
||||
await window.renderEntityTags('case', caseId, 'case-tags');
|
||||
}
|
||||
await loadCaseTagsModule();
|
||||
@ -5186,6 +5360,7 @@
|
||||
valueEl.textContent = 'Ingen åbne todo-opgaver';
|
||||
metaEl.textContent = 'Alt er færdigt';
|
||||
setNextTodoOverrideId(null);
|
||||
applyTopbarImportanceBubbles();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -5201,6 +5376,104 @@
|
||||
metaEl.textContent = nextStep.due_date
|
||||
? `Forfald: ${formatTodoDate(nextStep.due_date)}`
|
||||
: '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) {
|
||||
@ -10201,10 +10474,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!caseAnyDeskModal) {
|
||||
const modalEl = document.getElementById('caseAnyDeskModal');
|
||||
if (!modalEl) return;
|
||||
caseAnyDeskModal = new bootstrap.Modal(modalEl);
|
||||
const modalEl = document.getElementById('caseAnyDeskModal');
|
||||
if (!modalEl || typeof bootstrap === 'undefined' || !bootstrap.Modal) return;
|
||||
|
||||
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');
|
||||
@ -10241,7 +10519,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
caseAnyDeskModal.show();
|
||||
if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
|
||||
caseAnyDeskModal.show();
|
||||
}
|
||||
}
|
||||
|
||||
async function registerCaseAnyDeskSession() {
|
||||
@ -10301,7 +10581,9 @@
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
if (caseAnyDeskModal) caseAnyDeskModal.hide();
|
||||
if (caseAnyDeskModal && typeof caseAnyDeskModal.hide === 'function') {
|
||||
caseAnyDeskModal.hide();
|
||||
}
|
||||
|
||||
if (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() {
|
||||
if (typeof loadModulePrefs === 'function') {
|
||||
await loadModulePrefs();
|
||||
@ -11091,6 +11421,8 @@
|
||||
if (initialValue) {
|
||||
select.value = Array.from(known.values()).find((v) => v.toLowerCase() === initialValue.toLowerCase()) || initialValue;
|
||||
}
|
||||
|
||||
applyTopbarImportanceBubbles();
|
||||
}
|
||||
|
||||
function saveCaseTypeFromTopbar() {
|
||||
@ -11368,6 +11700,7 @@
|
||||
if (!el) return;
|
||||
el.setAttribute('data-has-content', hasContent ? 'true' : 'false');
|
||||
applyViewLayout(currentCaseView);
|
||||
updateCaseTabCountBadges();
|
||||
}
|
||||
|
||||
function applyViewLayout(viewName) {
|
||||
@ -11432,6 +11765,13 @@
|
||||
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.
|
||||
if (pref === false) {
|
||||
setVisibility(false);
|
||||
@ -11446,13 +11786,6 @@
|
||||
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
|
||||
if (standardModuleSet.has(moduleName)) {
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tags/entity/case/${caseIds}`);
|
||||
@ -11517,6 +11907,7 @@
|
||||
const tags = await res.json();
|
||||
const viewTag = tags.find(t => ['Pipeline', 'Kundevisning', 'Sag-detalje'].includes(t.name));
|
||||
applyViewLayout(viewTag ? viewTag.name : 'Sag-detalje');
|
||||
updateCaseTabCountBadges();
|
||||
} catch (e) {
|
||||
console.error('View tag lookup failed', e);
|
||||
}
|
||||
@ -11532,6 +11923,7 @@
|
||||
return acc;
|
||||
}, {});
|
||||
modulePrefs.time = true;
|
||||
updateCaseTabCountBadges();
|
||||
} catch (e) {
|
||||
console.error('Module prefs load failed', e);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user