diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index e78b092..1e6b52e 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -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)); + } + } {% endblock %} {% block content %} -
+
@@ -2404,16 +2480,38 @@
-
-
Status
+
+
Status
-
-
Ansvarlig
+
+
Type
+ +
+
+
Prioritet
+ +
+
+
Ansvarlig
-
-
Gruppe
+
+
Gruppe
-
-
Næste
+
+
Næste
Henter næste todo...
-
@@ -2445,34 +2543,12 @@
-
-
Type
- -
-
-
Prioritet
- -
-
Oprettelses dato
+
Oprettelses dato
{{ case.created_at.strftime('%d/%m/%Y') if case.created_at else '-' }}
-
Arbejdsstart
+
Arbejdsstart
-
Start senest
+
Start senest
-
Deadline dato
+
Deadline dato
@@ -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 = '
Ingen tags paaa sagen endnu
'; setModuleContentState('tags', false); @@ -4936,6 +5083,13 @@ `).join(''); + if (usingLegacyCaseTags) { + moduleContainer.insertAdjacentHTML( + 'beforeend', + '
Viser tags via sag-endpoint
' + ); + } + 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); }