- Added multiple test cases for the sag module to ensure proper functionality and data handling. - Created new templates for knowledge detail and knowledge index pages to display articles and solutions. - Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions. - Added a script to reconcile known internet connections with verified data. - Planned the implementation of a new website content administration module for managing customer references and operational status.
1903 lines
80 KiB
HTML
1903 lines
80 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Sager - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.search-bar {
|
|
position: relative;
|
|
margin-bottom: 0;
|
|
flex: 1 1 460px;
|
|
min-width: 240px;
|
|
}
|
|
|
|
.search-bar input {
|
|
min-height: 46px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(15,76,117,.15);
|
|
padding: 0.6rem 4.5rem 0.6rem 2.7rem;
|
|
background: var(--bg-card);
|
|
box-shadow: 0 4px 16px rgba(15, 76, 117, .06);
|
|
transition: border-color .16s ease, box-shadow .16s ease;
|
|
}
|
|
|
|
.search-bar input:focus {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px rgba(15,76,117,.12), 0 5px 18px rgba(15,76,117,.08);
|
|
}
|
|
|
|
.search-bar .search-icon {
|
|
position: absolute; left: .95rem; top: 50%; transform: translateY(-50%);
|
|
color: var(--accent); pointer-events: none;
|
|
}
|
|
|
|
.search-clear-btn {
|
|
position: absolute; right: .45rem; top: 50%; transform: translateY(-50%);
|
|
width: 34px; height: 34px; border: 0; border-radius: 9px;
|
|
background: transparent; color: var(--text-secondary);
|
|
}
|
|
|
|
.search-clear-btn:hover { background: rgba(15,76,117,.09); color: var(--accent); }
|
|
|
|
.search-shortcut {
|
|
position:absolute; right:2.85rem; top:50%; transform:translateY(-50%);
|
|
padding:.1rem .35rem; border:1px solid rgba(0,0,0,.12); border-radius:5px;
|
|
color:var(--text-secondary); font-size:.68rem; background:rgba(255,255,255,.7);
|
|
}
|
|
|
|
.sag-toolbar-card { padding: .85rem; margin-bottom: .9rem; background: var(--bg-card); border:1px solid rgba(15,76,117,.09); border-radius:16px; box-shadow:0 5px 20px rgba(15,76,117,.055); }
|
|
.sag-quick-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.48rem; padding-top:.7rem; margin-top:.7rem; border-top:1px solid rgba(15,76,117,.08); }
|
|
.sag-quick-filter { display:inline-flex; align-items:center; gap:.4rem; padding:.43rem .72rem; border:1px solid rgba(15,76,117,.14); border-radius:999px; background:rgba(15,76,117,.035); color:var(--text-primary); font-size:.8rem; font-weight:650; transition:all .16s ease; }
|
|
.sag-quick-filter:hover { border-color:rgba(15,76,117,.32); background:rgba(15,76,117,.09); color:var(--accent); transform:translateY(-1px); }
|
|
.sag-quick-filter.active { color:#fff; background:var(--accent); border-color:var(--accent); box-shadow:0 4px 12px rgba(15,76,117,.2); }
|
|
.sag-quick-filter[data-quick-filter="overdue"].active { background:#c92a2a; border-color:#c92a2a; }
|
|
.sag-advanced-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:1rem; padding:.6rem .7rem; border-radius:12px; background:rgba(15,76,117,.035); border:1px solid rgba(15,76,117,.07); }
|
|
|
|
.top-controls-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
|
|
.top-controls-actions {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
margin-left: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.top-controls-actions .btn {
|
|
white-space: nowrap;
|
|
padding-top: 0.4rem;
|
|
padding-bottom: 0.4rem;
|
|
}
|
|
|
|
.table-wrapper {
|
|
background: var(--bg-card);
|
|
border-radius: 12px;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.sag-table {
|
|
width: 100%;
|
|
min-width: 1760px;
|
|
margin: 0;
|
|
}
|
|
|
|
.sag-table thead {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.sag-table thead th {
|
|
padding: 0.6rem 0.75rem;
|
|
font-weight: 600;
|
|
font-size: 0.78rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
border: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.sag-table thead th.col-expand,
|
|
.sag-table tbody td.col-expand {
|
|
width: 44px;
|
|
min-width: 44px;
|
|
max-width: 44px;
|
|
text-align: center;
|
|
padding-left: 0.45rem;
|
|
padding-right: 0.45rem;
|
|
}
|
|
|
|
.sag-table tbody tr {
|
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|
transition: all 0.2s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.sag-table tbody tr:nth-child(even) {
|
|
background: rgba(15, 76, 117, 0.04);
|
|
}
|
|
|
|
.sag-table tbody tr:hover {
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.sag-table tbody tr.sag-deadline-overdue {
|
|
background: rgba(201, 42, 42, 0.045);
|
|
box-shadow: inset 5px 0 0 #c92a2a, inset 0 0 18px rgba(201, 42, 42, 0.11);
|
|
}
|
|
|
|
.sag-table tbody tr.sag-deadline-overdue:hover {
|
|
background: rgba(201, 42, 42, 0.09);
|
|
box-shadow: inset 5px 0 0 #b42318, inset 0 0 22px rgba(201, 42, 42, 0.16);
|
|
}
|
|
|
|
.sag-table tbody tr.sag-deadline-overdue td:last-child {
|
|
color: #b42318 !important;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.sag-table tbody td {
|
|
padding: 0.5rem 0.75rem;
|
|
vertical-align: top;
|
|
font-size: 0.86rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.sag-table td.col-company,
|
|
.sag-table td.col-contact,
|
|
.sag-table td.col-owner,
|
|
.sag-table td.col-group,
|
|
.sag-table td.col-desc {
|
|
white-space: normal;
|
|
}
|
|
|
|
.sag-table td.col-company,
|
|
.sag-table td.col-contact,
|
|
.sag-table td.col-owner,
|
|
.sag-table td.col-group {
|
|
max-width: 180px;
|
|
}
|
|
|
|
.sag-table td.col-desc {
|
|
min-width: 260px;
|
|
max-width: 360px;
|
|
}
|
|
|
|
.sag-column-list { min-width: 260px; max-height: 420px; overflow-y: auto; }
|
|
.sag-column-item { display:flex; align-items:center; gap:.55rem; padding:.45rem .6rem; border-radius:8px; cursor:grab; }
|
|
.sag-column-item:hover, .sag-column-item.dragging { background:rgba(15,76,117,.1); }
|
|
.sag-column-item .drag-handle { color:var(--text-secondary); cursor:grab; }
|
|
|
|
.sag-id {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.28rem;
|
|
padding: 0.22rem 0.48rem;
|
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
|
border-radius: 7px;
|
|
background: rgba(15, 76, 117, 0.07);
|
|
color: var(--accent);
|
|
font-weight: 700;
|
|
font-size: 0.82rem;
|
|
line-height: 1.2;
|
|
text-decoration: none;
|
|
transition: background-color .16s ease, border-color .16s ease, transform .16s ease;
|
|
}
|
|
|
|
.sag-id:hover {
|
|
color: var(--accent);
|
|
background: rgba(15, 76, 117, 0.14);
|
|
border-color: rgba(15, 76, 117, 0.3);
|
|
transform: translateY(-1px);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.sag-entity-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.38rem;
|
|
max-width: 100%;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 7px;
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
line-height: 1.25;
|
|
text-decoration: none;
|
|
transition: color .16s ease, background-color .16s ease, transform .16s ease;
|
|
}
|
|
|
|
.sag-entity-link i {
|
|
flex: 0 0 auto;
|
|
color: var(--text-secondary);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.sag-entity-link span {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.sag-entity-link:hover {
|
|
color: var(--accent);
|
|
background: rgba(15, 76, 117, 0.09);
|
|
text-decoration: none;
|
|
transform: translateX(2px);
|
|
}
|
|
|
|
.sag-entity-link:hover i {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.sag-id:focus-visible,
|
|
.sag-entity-link:focus-visible {
|
|
outline: 3px solid rgba(15, 76, 117, 0.22);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.sag-unread-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 1.35rem;
|
|
height: 1.35rem;
|
|
padding: 0 0.35rem;
|
|
margin-left: 0.45rem;
|
|
border-radius: 999px;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
vertical-align: middle;
|
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
|
|
}
|
|
|
|
.sag-unread-fresh {
|
|
background: #2f9e44;
|
|
}
|
|
|
|
.sag-unread-warm {
|
|
background: #f08c00;
|
|
}
|
|
|
|
.sag-unread-hot {
|
|
background: #c92a2a;
|
|
animation: sagUnreadPulse 1.8s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes sagUnreadPulse {
|
|
0% { transform: scale(1); }
|
|
50% { transform: scale(1.08); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
|
|
.sag-titel {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.sag-beskrivelse {
|
|
color: var(--text-secondary);
|
|
font-size: 0.8rem;
|
|
margin-top: 0.2rem;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 1;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Tree view styles */
|
|
.tree-row {
|
|
position: relative;
|
|
}
|
|
|
|
.tree-row.has-children {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.tree-row.has-children:before {
|
|
content: none;
|
|
}
|
|
|
|
.tree-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
background: var(--accent-light);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.75rem;
|
|
color: var(--accent);
|
|
font-weight: bold;
|
|
position: absolute;
|
|
left: 0.5rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
.col-expand .tree-toggle {
|
|
position: static;
|
|
transform: none;
|
|
}
|
|
|
|
.tree-toggle:hover {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.tree-child {
|
|
background: rgba(0,0,0,0.02);
|
|
}
|
|
|
|
.tree-child td {
|
|
padding: 0.5rem 1rem !important;
|
|
border-top: none !important;
|
|
}
|
|
|
|
.tree-child .child-branch {
|
|
color: rgba(0,0,0,0.4);
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
}
|
|
|
|
.relation-badge {
|
|
display: inline-block;
|
|
font-size: 0.65rem;
|
|
padding: 0.25rem 0.6rem;
|
|
background: var(--accent);
|
|
color: white;
|
|
border-radius: 12px;
|
|
margin-right: 0.5rem;
|
|
font-weight: 600;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 0.35rem 0.8rem;
|
|
border-radius: 20px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
background: #e9ecef;
|
|
color: #495057;
|
|
}
|
|
|
|
.status-åben {
|
|
background: #fff3cd;
|
|
color: #856404;
|
|
}
|
|
|
|
.status-lukket {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
}
|
|
|
|
.status-under-behandling,
|
|
.status-i-gang,
|
|
.status-in-progress {
|
|
background: #dbeafe;
|
|
color: #1e3a8a;
|
|
}
|
|
|
|
.status-afventer,
|
|
.status-on-hold {
|
|
background: #fef3c7;
|
|
color: #92400e;
|
|
}
|
|
|
|
.status-løst,
|
|
.status-afsluttet,
|
|
.status-resolved,
|
|
.status-done,
|
|
.status-closed {
|
|
background: #d1fae5;
|
|
color: #065f46;
|
|
}
|
|
|
|
.sag-inline-select {
|
|
min-height: 34px;
|
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
|
border-radius: 9px;
|
|
background-color: rgba(15, 76, 117, 0.045);
|
|
color: var(--text-primary);
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
padding: 0.34rem 2rem 0.34rem 0.62rem;
|
|
box-shadow: none;
|
|
cursor: pointer;
|
|
transition: border-color .16s ease, background-color .16s ease, box-shadow .16s ease, transform .16s ease;
|
|
}
|
|
|
|
.sag-inline-select:hover:not(:disabled) {
|
|
border-color: rgba(15, 76, 117, 0.38);
|
|
background-color: rgba(15, 76, 117, 0.09);
|
|
}
|
|
|
|
.sag-inline-select:focus {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.14);
|
|
}
|
|
|
|
.sag-inline-select:disabled {
|
|
opacity: .62;
|
|
cursor: wait;
|
|
}
|
|
|
|
.sag-owner-select {
|
|
background-color: rgba(99, 102, 241, 0.055);
|
|
border-color: rgba(99, 102, 241, 0.17);
|
|
}
|
|
|
|
.sag-status-select.status-tone-open {
|
|
color: #8a5a00;
|
|
background-color: #fff8df;
|
|
border-color: #efd991;
|
|
}
|
|
|
|
.sag-status-select.status-tone-progress {
|
|
color: #174a89;
|
|
background-color: #eaf3ff;
|
|
border-color: #b8d5fa;
|
|
}
|
|
|
|
.sag-status-select.status-tone-waiting {
|
|
color: #8a4600;
|
|
background-color: #fff1df;
|
|
border-color: #f0c58e;
|
|
}
|
|
|
|
.sag-status-select.status-tone-done {
|
|
color: #087044;
|
|
background-color: #e5f8ef;
|
|
border-color: #acdcbc;
|
|
}
|
|
|
|
.filter-pills {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filter-pill {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 20px;
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
background: var(--bg-card);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.filter-pill:hover {
|
|
background: var(--accent-light);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.filter-pill.active {
|
|
background: var(--accent);
|
|
color: white;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.stats-bar {
|
|
display: inline-flex;
|
|
gap: 0.4rem;
|
|
margin-bottom: 0;
|
|
padding: 0.25rem;
|
|
background: var(--bg-card);
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stat-item {
|
|
text-align: center;
|
|
padding: 0.2rem 0.5rem;
|
|
border-radius: 999px;
|
|
background: rgba(0,0,0,0.03);
|
|
line-height: 1.15;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 0.92rem;
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.62rem;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.35px;
|
|
}
|
|
|
|
.type-filter-wrap {
|
|
min-width: 200px;
|
|
max-width: 280px;
|
|
}
|
|
|
|
.type-filter-header {
|
|
display: none;
|
|
}
|
|
|
|
.type-filter-dropdown .dropdown-toggle {
|
|
width: 100%;
|
|
text-align: left;
|
|
border-radius: 0.375rem;
|
|
border: 1px solid #ced4da;
|
|
background: #fff;
|
|
color: var(--text-primary);
|
|
font-weight: 400;
|
|
font-size: 0.86rem;
|
|
padding: 0.28rem 2rem 0.28rem 0.65rem;
|
|
min-height: calc(1.4em + 0.56rem + 2px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.6rem;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.type-filter-dropdown .dropdown-toggle:hover,
|
|
.type-filter-dropdown .dropdown-toggle:focus,
|
|
.type-filter-dropdown .dropdown-toggle:active,
|
|
.type-filter-dropdown .dropdown-toggle.show {
|
|
border-color: #86b7fe;
|
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
|
color: var(--text-primary);
|
|
background: #fff;
|
|
}
|
|
|
|
.type-filter-dropdown .dropdown-toggle::after {
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.type-filter-dropdown .dropdown-menu {
|
|
width: min(340px, 92vw);
|
|
border-radius: 0.5rem;
|
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
padding: 0.45rem;
|
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.type-filter-checkbox-list {
|
|
max-height: 12.5rem;
|
|
overflow-y: auto;
|
|
padding-right: 0.2rem;
|
|
}
|
|
|
|
.type-filter-checkbox-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
padding: 0.34rem 0.42rem;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
margin-bottom: 0.2rem;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.type-filter-checkbox-item:hover {
|
|
background: rgba(15, 76, 117, 0.08);
|
|
}
|
|
|
|
.type-filter-checkbox-item input {
|
|
margin-top: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.type-filter-checkbox-label {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: #16384f;
|
|
user-select: none;
|
|
}
|
|
|
|
.type-filter-menu-actions {
|
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
|
margin-top: 0.35rem;
|
|
padding-top: 0.38rem;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.type-filter-menu-actions .btn {
|
|
font-size: 0.72rem;
|
|
line-height: 1.2;
|
|
padding: 0.2rem 0.45rem;
|
|
}
|
|
|
|
.type-filter-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 0.6rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.type-filter-selection {
|
|
font-size: 0.73rem;
|
|
color: var(--text-secondary);
|
|
min-height: 1rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
flex: 1;
|
|
}
|
|
|
|
.type-filter-empty {
|
|
font-size: 0.8rem;
|
|
color: var(--text-secondary);
|
|
padding: 0.22rem 0.1rem;
|
|
}
|
|
|
|
.mini-filter-wrap {
|
|
min-width: 190px;
|
|
max-width: 260px;
|
|
}
|
|
|
|
.mini-filter-dropdown .dropdown-toggle {
|
|
width: 100%;
|
|
text-align: left;
|
|
border-radius: 0.375rem;
|
|
border: 1px solid #ced4da;
|
|
background: #fff;
|
|
color: var(--text-primary);
|
|
font-weight: 400;
|
|
font-size: 0.86rem;
|
|
padding: 0.28rem 2rem 0.28rem 0.65rem;
|
|
min-height: calc(1.4em + 0.56rem + 2px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.mini-filter-dropdown .dropdown-toggle:hover,
|
|
.mini-filter-dropdown .dropdown-toggle:focus,
|
|
.mini-filter-dropdown .dropdown-toggle:active,
|
|
.mini-filter-dropdown .dropdown-toggle.show {
|
|
border-color: #86b7fe;
|
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
|
color: var(--text-primary);
|
|
background: #fff;
|
|
}
|
|
|
|
.mini-filter-dropdown .dropdown-menu {
|
|
width: min(320px, 92vw);
|
|
border-radius: 0.5rem;
|
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
padding: 0.45rem;
|
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.mini-filter-list {
|
|
max-height: 12.5rem;
|
|
overflow-y: auto;
|
|
padding-right: 0.2rem;
|
|
}
|
|
|
|
.mini-filter-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.52rem;
|
|
padding: 0.34rem 0.42rem;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
margin-bottom: 0.15rem;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.mini-filter-item:hover {
|
|
background: rgba(15, 76, 117, 0.08);
|
|
}
|
|
|
|
.mini-filter-item input {
|
|
margin-top: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.mini-filter-label {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: #16384f;
|
|
user-select: none;
|
|
}
|
|
|
|
.mini-filter-footer {
|
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
|
margin-top: 0.35rem;
|
|
padding-top: 0.35rem;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.mini-filter-clear {
|
|
font-size: 0.72rem;
|
|
line-height: 1.2;
|
|
padding: 0.2rem 0.45rem;
|
|
}
|
|
|
|
.owner-cell {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.owner-avatar {
|
|
width: 1.75rem;
|
|
height: 1.75rem;
|
|
border-radius: 999px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
color: #fff;
|
|
background: var(--accent);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.owner-avatar.group-avatar {
|
|
background: #7c3aed;
|
|
}
|
|
|
|
.owner-avatar.empty-avatar {
|
|
background: #cbd5e1;
|
|
color: #475569;
|
|
}
|
|
|
|
.owner-name {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
word-break: break-word;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.top-controls-row {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.top-controls-actions {
|
|
margin-left: 0;
|
|
}
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 3rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 3rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.sag-top-alerts {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.sag-top-alerts .alert {
|
|
border-left: 6px solid;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
}
|
|
|
|
.sag-top-alerts .alert-warning {
|
|
border-left-color: #f59f00;
|
|
}
|
|
|
|
.sag-top-alerts .alert-danger {
|
|
border-left-color: #e03131;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{% macro initials_bubble(label, group=false) -%}
|
|
{% if label %}
|
|
{% set norm = label.strip() %}
|
|
{% set parts = norm.split() %}
|
|
{% set initials = ((parts[0][0] if parts|length > 0 else norm[0]) ~ (parts[1][0] if parts|length > 1 else ''))|upper %}
|
|
<span class="owner-avatar {% if group %}group-avatar{% endif %}" title="{{ norm }}">{{ initials }}</span>
|
|
{% else %}
|
|
<span class="owner-avatar empty-avatar" title="Ikke sat">-</span>
|
|
{% endif %}
|
|
{%- endmacro %}
|
|
|
|
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
|
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
|
|
|
<div class="sag-toolbar-card">
|
|
<div class="top-controls-row mb-0">
|
|
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
|
|
<i class="bi bi-list-check"></i>
|
|
</h1>
|
|
|
|
<div class="stats-bar">
|
|
<div class="stat-item">
|
|
<div class="stat-value">{{ sager|length }}</div>
|
|
<div class="stat-label">Total</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
|
<div class="stat-label">Åbne</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-bar">
|
|
<i class="bi bi-search search-icon"></i>
|
|
<input type="text"
|
|
class="form-control"
|
|
id="searchInput"
|
|
placeholder="Søg på sagsnr., firma, kontakt, titel eller ansvarlig..."
|
|
autocomplete="off">
|
|
<span class="search-shortcut">/</span>
|
|
<button class="search-clear-btn d-none" id="clearSearchBtn" type="button" title="Ryd søgning" aria-label="Ryd søgning"><i class="bi bi-x-lg"></i></button>
|
|
</div>
|
|
|
|
<div class="top-controls-actions">
|
|
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
|
|
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
|
</button>
|
|
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
|
|
<i class="bi bi-plus-lg me-2"></i>Ny Sag
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="sag-quick-filters" aria-label="Hurtigfiltre">
|
|
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'all' else '' }}" type="button" data-quick-filter="all"><i class="bi bi-grid"></i>Alle aktive</button>
|
|
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'mine-open' else '' }}" type="button" data-quick-filter="mine-open"><i class="bi bi-person-check"></i>Mine åbne sager</button>
|
|
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'overdue' else '' }}" type="button" data-quick-filter="overdue"><i class="bi bi-alarm"></i>Overskredet deadline</button>
|
|
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'my-groups' else '' }}" type="button" data-quick-filter="my-groups"><i class="bi bi-people"></i>Mine grupper</button>
|
|
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'unassigned' else '' }}" type="button" data-quick-filter="unassigned"><i class="bi bi-person-dash"></i>Ikke tildelt</button>
|
|
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'closed' else '' }}" type="button" data-quick-filter="closed"><i class="bi bi-check2-circle"></i>Lukkede</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sag-advanced-filters">
|
|
<div class="type-filter-wrap">
|
|
<div class="dropdown type-filter-dropdown mb-1">
|
|
<button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
|
<span id="typeFilterDropdownLabel">Vælg typer</span>
|
|
</button>
|
|
<div class="dropdown-menu" aria-labelledby="typeFilterDropdownBtn">
|
|
<div class="type-filter-checkbox-list" id="typeFilterCheckboxList"></div>
|
|
<div class="type-filter-menu-actions">
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" id="clearTypeFilterBtn">Ryd</button>
|
|
<button class="btn btn-sm btn-primary" type="button" id="saveTypeFilterDefaultBtn">Gem</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<select class="d-none" id="typeFilter" multiple title="Type filter data source"></select>
|
|
</div>
|
|
<div class="mini-filter-wrap">
|
|
<div class="dropdown mini-filter-dropdown mb-1">
|
|
<button class="btn dropdown-toggle" type="button" id="assigneeDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
|
<span id="assigneeDropdownLabel">Ansvarlig</span>
|
|
</button>
|
|
<div class="dropdown-menu" aria-labelledby="assigneeDropdownBtn">
|
|
<div class="mini-filter-list" id="assigneeFilterList"></div>
|
|
<div class="mini-filter-footer">
|
|
<button class="btn btn-sm btn-outline-secondary mini-filter-clear" type="button" id="clearAssigneeFilterBtn">Ryd</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<select class="d-none" name="ansvarlig_bruger_id" id="assigneeFilter" multiple>
|
|
<option value="">Alle medarbejdere</option>
|
|
<option value="__UNASSIGNED__" {% if current_unassigned %}selected{% endif %}>Uden ansvarlig</option>
|
|
{% for user in assignment_users or [] %}
|
|
<option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="mini-filter-wrap">
|
|
<div class="dropdown mini-filter-dropdown mb-1">
|
|
<button class="btn dropdown-toggle" type="button" id="groupDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
|
<span id="groupDropdownLabel">Grupper</span>
|
|
</button>
|
|
<div class="dropdown-menu" aria-labelledby="groupDropdownBtn">
|
|
<div class="mini-filter-list" id="groupFilterList"></div>
|
|
<div class="mini-filter-footer">
|
|
<button class="btn btn-sm btn-outline-secondary mini-filter-clear" type="button" id="clearGroupFilterBtn">Ryd</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<select class="d-none" name="assigned_group_id" id="groupFilter" multiple>
|
|
<option value="">Alle grupper</option>
|
|
{% for group in assignment_groups or [] %}
|
|
<option value="{{ group.id }}" {% if current_assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
|
|
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
|
</a>
|
|
<div class="dropdown ms-auto">
|
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
|
<i class="bi bi-layout-three-columns me-1"></i>Kolonner
|
|
</button>
|
|
<div class="dropdown-menu dropdown-menu-end p-2 shadow">
|
|
<div class="small text-muted px-2 pb-2">Træk for at ændre rækkefølge</div>
|
|
<div id="sagColumnList" class="sag-column-list"></div>
|
|
<div class="d-flex gap-2 border-top pt-2 mt-2">
|
|
<button id="resetSagColumnsBtn" class="btn btn-sm btn-outline-secondary" type="button">Nulstil</button>
|
|
<button id="saveSagColumnsBtn" class="btn btn-sm btn-primary ms-auto" type="button">Gem for mig</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="table-wrapper">
|
|
{% if sager %}
|
|
<table class="sag-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-expand"></th>
|
|
<th style="width: 90px;">SagsID</th>
|
|
<th style="width: 180px;">Virksom.</th>
|
|
<th style="width: 150px;">Kontakt</th>
|
|
<th style="width: 300px;">Beskr.</th>
|
|
<th style="width: 120px;">Type</th>
|
|
<th style="width: 110px;">Prioritet</th>
|
|
<th style="width: 120px;">Status</th>
|
|
<th style="width: 160px;">Ansvarl.</th>
|
|
<th style="width: 170px;">Gruppe/Level</th>
|
|
<th style="width: 240px;">Næste todo</th>
|
|
<th style="width: 120px;">Opret.</th>
|
|
<th style="width: 120px;">Arbejdsstart</th>
|
|
<th style="width: 140px;">Start senest</th>
|
|
<th style="width: 120px;">Deadline</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="sagTableBody">
|
|
{% for sag in sager %}
|
|
{% if sag.id not in child_ids %}
|
|
{% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %}
|
|
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
|
|
data-sag-id="{{ sag.id }}"
|
|
data-status="{{ sag.status }}"
|
|
data-type="{{ sag.template_key or sag.type or 'ticket' }}"
|
|
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}"
|
|
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}"
|
|
data-deadline="{{ sag.deadline.isoformat() if sag.deadline else '' }}">
|
|
<td class="col-expand" onclick="event.stopPropagation();">
|
|
{% if has_relations %}
|
|
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<a class="sag-id" href="/sag/{{ sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ sag.id }}</a>
|
|
{% if (sag.unread_email_count or 0) > 0 %}
|
|
{% set unread_level = sag.unread_email_level or 'fresh' %}
|
|
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
|
|
{{ sag.unread_email_count if sag.unread_email_count <= 99 else '99+' }}
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
|
{% if sag.customer_id and sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.customer_name }}"><i class="bi bi-building"></i><span>{{ sag.customer_name }}</span></a>{% else %}-{% endif %}
|
|
</td>
|
|
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
|
{% if sag.kontakt_id and sag.kontakt_navn and sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
|
|
</td>
|
|
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
|
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
|
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
|
{{ sag.priority if sag.priority else 'normal' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
|
{% set status_raw = sag.status if sag.status else 'åben' %}
|
|
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'status', this.value, this)">
|
|
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
|
|
</select>
|
|
</td>
|
|
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
|
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
|
|
<option value="">Ikke tildelt</option>
|
|
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
|
|
</select>
|
|
</td>
|
|
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
|
<div class="owner-cell">
|
|
{{ initials_bubble(sag.assigned_group_name, true) }}
|
|
</div>
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
|
|
{% if sag.next_todo_title %}
|
|
<div>{{ sag.next_todo_title }}</div>
|
|
{% if sag.next_todo_due_date %}
|
|
<div class="small text-muted">Forfald: {{ sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
|
|
{% endif %}
|
|
{% else %}
|
|
-
|
|
{% endif %}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
|
|
</td>
|
|
</tr>
|
|
{% if has_relations %}
|
|
{% set seen_targets = [] %}
|
|
{% for rel in relations_map[sag.id] %}
|
|
{% set related_sag = sager|selectattr('id', 'equalto', rel.target_id)|first %}
|
|
{% if related_sag and rel.target_id not in seen_targets %}
|
|
{% set _ = seen_targets.append(rel.target_id) %}
|
|
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
|
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" data-deadline="{{ related_sag.deadline.isoformat() if related_sag.deadline else '' }}" style="display: none;">
|
|
<td class="col-expand"><span class="child-branch">└</span></td>
|
|
<td>
|
|
<a class="sag-id" href="/sag/{{ related_sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ related_sag.id }}</a>
|
|
{% if (related_sag.unread_email_count or 0) > 0 %}
|
|
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
|
|
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
|
|
{{ related_sag.unread_email_count if related_sag.unread_email_count <= 99 else '99+' }}
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
|
{% if related_sag.customer_id and related_sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ related_sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.customer_name }}"><i class="bi bi-building"></i><span>{{ related_sag.customer_name }}</span></a>{% else %}-{% endif %}
|
|
</td>
|
|
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
|
{% if related_sag.kontakt_id and related_sag.kontakt_navn and related_sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ related_sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ related_sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
|
|
</td>
|
|
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
|
{% for rt in all_rel_types %}
|
|
<span class="relation-badge">{{ rt }}</span>
|
|
{% endfor %}
|
|
<div class="sag-titel" style="display: inline;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
|
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
|
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
|
{% set related_status_raw = related_sag.status if related_sag.status else 'åben' %}
|
|
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ related_status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'status', this.value, this)">
|
|
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == related_status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
|
|
</select>
|
|
</td>
|
|
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
|
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ related_sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
|
|
<option value="">Ikke tildelt</option>
|
|
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if related_sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
|
|
</select>
|
|
</td>
|
|
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
|
<div class="owner-cell">
|
|
{{ initials_bubble(related_sag.assigned_group_name, true) }}
|
|
</div>
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
|
|
{% if related_sag.next_todo_title %}
|
|
<div>{{ related_sag.next_todo_title }}</div>
|
|
{% if related_sag.next_todo_due_date %}
|
|
<div class="small text-muted">Forfald: {{ related_sag.next_todo_due_date.strftime('%d/%m-%Y') }}</div>
|
|
{% endif %}
|
|
{% else %}
|
|
-
|
|
{% endif %}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
|
|
</td>
|
|
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
|
|
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
|
|
</td>
|
|
</tr>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<i class="bi bi-inbox"></i>
|
|
<p>Ingen sager fundet</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="closeCaseWithoutTimeModal" tabindex="-1" aria-labelledby="closeCaseWithoutTimeTitle" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
<div class="modal-content border-0 shadow-lg">
|
|
<div class="modal-header bg-danger text-white border-0 py-4">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<i class="bi bi-exclamation-triangle-fill" style="font-size:3rem;line-height:1"></i>
|
|
<div>
|
|
<div class="text-uppercase fw-bold small opacity-75 mb-1">Vigtig advarsel</div>
|
|
<h2 class="modal-title fw-bold mb-0" id="closeCaseWithoutTimeTitle">Der er ikke registreret tid på sagen</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-body p-4 p-md-5 text-center">
|
|
<p class="fs-4 fw-semibold mb-3">Du er ved at lukke sag <span id="closeWithoutTimeCaseNumber"></span> uden tidsregistrering.</p>
|
|
<p class="fs-5 text-muted mb-0">Kontrollér, at det er korrekt. Sagen bliver kun lukket, hvis du aktivt bekræfter nedenfor.</p>
|
|
</div>
|
|
<div class="modal-footer border-0 bg-light p-4 justify-content-center gap-2">
|
|
<button type="button" class="btn btn-lg btn-outline-secondary px-4" data-bs-dismiss="modal">
|
|
<i class="bi bi-arrow-left me-2"></i>Gå tilbage
|
|
</button>
|
|
<button type="button" class="btn btn-lg btn-danger px-4" id="confirmCloseCaseWithoutTimeBtn">
|
|
<i class="bi bi-check-circle-fill me-2"></i>Ja, luk sagen uden tid
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
|
|
|
|
function applyStatusSelectTone(control) {
|
|
if (!control) return;
|
|
const status = String(control.value || '').trim().toLowerCase();
|
|
control.classList.remove('status-tone-open', 'status-tone-progress', 'status-tone-waiting', 'status-tone-done');
|
|
let tone = 'status-tone-open';
|
|
if (['under behandling', 'i gang', 'in progress'].includes(status)) tone = 'status-tone-progress';
|
|
else if (['afventer', 'on hold'].includes(status)) tone = 'status-tone-waiting';
|
|
else if (['løst', 'lukket', 'afsluttet', 'resolved', 'closed', 'done'].includes(status)) tone = 'status-tone-done';
|
|
control.classList.add(tone);
|
|
}
|
|
|
|
document.querySelectorAll('.sag-status-select').forEach(applyStatusSelectTone);
|
|
|
|
function confirmCloseCaseWithoutTime(caseId, message) {
|
|
return new Promise(resolve => {
|
|
const modalElement = document.getElementById('closeCaseWithoutTimeModal');
|
|
const confirmButton = document.getElementById('confirmCloseCaseWithoutTimeBtn');
|
|
const caseNumber = document.getElementById('closeWithoutTimeCaseNumber');
|
|
if (!modalElement || !confirmButton || typeof bootstrap === 'undefined') {
|
|
resolve(window.confirm(`${message}\n\nVil du lukke sagen uden tidsregistrering?`));
|
|
return;
|
|
}
|
|
|
|
if (caseNumber) caseNumber.textContent = `#${caseId}`;
|
|
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
|
|
let confirmed = false;
|
|
const handleConfirm = () => {
|
|
confirmed = true;
|
|
modal.hide();
|
|
};
|
|
const handleHidden = () => {
|
|
confirmButton.removeEventListener('click', handleConfirm);
|
|
modalElement.removeEventListener('hidden.bs.modal', handleHidden);
|
|
resolve(confirmed);
|
|
};
|
|
confirmButton.addEventListener('click', handleConfirm, { once: true });
|
|
modalElement.addEventListener('hidden.bs.modal', handleHidden, { once: true });
|
|
modal.show();
|
|
});
|
|
}
|
|
|
|
async function updateCaseListField(caseId, field, value, control) {
|
|
const previous = control?.dataset?.previous ?? '';
|
|
if (control) control.disabled = true;
|
|
try {
|
|
const saveField = async (confirmedWithoutTime = false) => {
|
|
const body = { [field]: value === '' ? null : value };
|
|
if (confirmedWithoutTime) body.confirm_close_without_time = true;
|
|
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
return { response, payload };
|
|
};
|
|
|
|
let { response, payload } = await saveField();
|
|
const detail = payload?.detail;
|
|
if (response.status === 409 && detail?.code === 'close_without_time_confirmation_required') {
|
|
const confirmed = await confirmCloseCaseWithoutTime(caseId, detail.message);
|
|
if (!confirmed) {
|
|
if (control) control.value = previous;
|
|
if (control && field === 'status') applyStatusSelectTone(control);
|
|
return;
|
|
}
|
|
({ response, payload } = await saveField(true));
|
|
}
|
|
if (!response.ok) {
|
|
const message = typeof payload.detail === 'string' ? payload.detail : payload.detail?.message;
|
|
throw new Error(message || 'Ændringen kunne ikke gemmes');
|
|
}
|
|
if (control) control.dataset.previous = String(value ?? '');
|
|
if (control && field === 'status') applyStatusSelectTone(control);
|
|
const row = control?.closest('tr');
|
|
if (row && field === 'status') row.dataset.status = String(value || '');
|
|
if (row && field === 'status') updateOverdueDeadlineMarker(row);
|
|
if (row && field === 'ansvarlig_bruger_id') row.dataset.assigneeId = String(value || '');
|
|
if (typeof applyFilters === 'function') applyFilters();
|
|
} catch (error) {
|
|
if (control) control.value = previous;
|
|
if (control && field === 'status') applyStatusSelectTone(control);
|
|
if (typeof showNotification === 'function') showNotification(error.message, 'error');
|
|
else window.alert(error.message);
|
|
} finally {
|
|
if (control) control.disabled = false;
|
|
}
|
|
}
|
|
|
|
function escapeTopAlertHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
async function loadSagTopAlertsForCustomer(customerId) {
|
|
const container = document.getElementById('sagTopAlerts');
|
|
if (!container || !customerId) {
|
|
return;
|
|
}
|
|
|
|
container.classList.remove('d-none');
|
|
container.innerHTML = '<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Henter kunde-alerts...</div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${customerId}`, {
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const alerts = (data?.alerts || []).filter((alert) => ['critical', 'warning'].includes(String(alert?.severity || '').toLowerCase()));
|
|
|
|
if (!alerts.length) {
|
|
container.classList.add('d-none');
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const html = alerts.map((alert) => {
|
|
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
|
|
const klass = isCritical ? 'alert-danger' : 'alert-warning';
|
|
const label = isCritical ? 'KRITISK' : 'ADVARSEL';
|
|
const title = escapeTopAlertHtml(alert.title || 'Vigtig kundeinformation');
|
|
const message = escapeTopAlertHtml(alert.message || '');
|
|
return `
|
|
<div class="alert ${klass} mb-2" role="alert">
|
|
<strong>${label}:</strong> ${title}
|
|
${message ? `<div class="small mt-1">${message}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
container.innerHTML = html;
|
|
container.classList.remove('d-none');
|
|
} catch (error) {
|
|
console.error('Failed to load customer alerts on sag list:', error);
|
|
container.innerHTML = '<div class="alert alert-warning mb-0" role="alert"><strong>Advarsel:</strong> Kunde-alerts kunne ikke hentes.</div>';
|
|
container.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
// Tree toggle functionality
|
|
function toggleTreeNode(event, sagId) {
|
|
event.stopPropagation();
|
|
const toggle = event.currentTarget || event.target;
|
|
const row = toggle.closest('tr');
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
if (row.classList.contains('expanded')) {
|
|
row.classList.remove('expanded');
|
|
toggle.textContent = '+';
|
|
} else {
|
|
row.classList.add('expanded');
|
|
toggle.textContent = '-';
|
|
}
|
|
|
|
applyFilters();
|
|
}
|
|
|
|
// Search functionality
|
|
const searchInput = document.getElementById('searchInput');
|
|
const allRows = document.querySelectorAll('.tree-row');
|
|
let currentSearch = '';
|
|
let currentFilter = 'all';
|
|
let currentQuickFilter = {{ (current_quick_filter or 'all')|tojson }};
|
|
let currentEmployeeId = '';
|
|
let currentEmployeeGroupIds = new Set();
|
|
let currentTypes = new Set();
|
|
let currentAssignees = new Set();
|
|
let currentGroups = new Set();
|
|
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
|
|
|
|
function isDeadlinePast(deadlineValue) {
|
|
if (!deadlineValue) return false;
|
|
const deadline = new Date(deadlineValue);
|
|
if (Number.isNaN(deadline.getTime())) return false;
|
|
deadline.setHours(0, 0, 0, 0);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
return deadline.getTime() < today.getTime();
|
|
}
|
|
|
|
function updateOverdueDeadlineMarker(row) {
|
|
if (!row) return;
|
|
const status = String(row.dataset.status || '').trim().toLowerCase();
|
|
const deadlineValue = String(row.dataset.deadline || '').trim();
|
|
const isOverdue = isDeadlinePast(deadlineValue)
|
|
&& !closedStatuses.has(status);
|
|
row.classList.toggle('sag-deadline-overdue', isOverdue);
|
|
}
|
|
|
|
document.querySelectorAll('.sag-table tbody tr[data-deadline]').forEach(updateOverdueDeadlineMarker);
|
|
|
|
const assigneeFilter = document.getElementById('assigneeFilter');
|
|
const groupFilter = document.getElementById('groupFilter');
|
|
const assigneeFilterList = document.getElementById('assigneeFilterList');
|
|
const groupFilterList = document.getElementById('groupFilterList');
|
|
const assigneeDropdownLabel = document.getElementById('assigneeDropdownLabel');
|
|
const groupDropdownLabel = document.getElementById('groupDropdownLabel');
|
|
const clearAssigneeFilterBtn = document.getElementById('clearAssigneeFilterBtn');
|
|
const clearGroupFilterBtn = document.getElementById('clearGroupFilterBtn');
|
|
|
|
function applyFilters() {
|
|
const search = currentSearch;
|
|
|
|
const matchesQuickFilter = (row, isClosed) => {
|
|
const assigneeId = String(row.dataset.assigneeId || '').trim();
|
|
const groupId = String(row.dataset.groupId || '').trim();
|
|
if (currentQuickFilter === 'mine-open') return !isClosed && !!currentEmployeeId && assigneeId === currentEmployeeId;
|
|
if (currentQuickFilter === 'my-groups') return !isClosed && currentEmployeeGroupIds.has(groupId);
|
|
if (currentQuickFilter === 'unassigned') return !isClosed && !assigneeId;
|
|
if (currentQuickFilter === 'closed') return isClosed;
|
|
if (currentQuickFilter === 'overdue') {
|
|
const deadline = String(row.dataset.deadline || '');
|
|
return !isClosed && isDeadlinePast(deadline);
|
|
}
|
|
return !isClosed;
|
|
};
|
|
|
|
allRows.forEach(row => {
|
|
const text = row.textContent.toLowerCase();
|
|
const status = String(row.dataset.status || '').toLowerCase();
|
|
const type = row.dataset.type || 'ticket';
|
|
const assigneeRaw = String(row.dataset.assigneeId || '').trim();
|
|
const groupRaw = String(row.dataset.groupId || '').trim();
|
|
const assigneeId = assigneeRaw || '__UNASSIGNED__';
|
|
const groupId = groupRaw;
|
|
const matchesSearch = search.split(/\s+/).filter(Boolean).every(term => text.includes(term));
|
|
const isClosed = closedStatuses.has(status);
|
|
const matchesFilter = currentFilter === 'all'
|
|
|| (currentFilter === 'åben' && !isClosed)
|
|
|| (currentFilter === 'lukket' && isClosed)
|
|
|| status === currentFilter;
|
|
const matchesType = currentTypes.size === 0 || currentTypes.has(type);
|
|
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
|
|
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
|
|
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup && matchesQuickFilter(row, isClosed);
|
|
|
|
row.style.display = visible ? '' : 'none';
|
|
|
|
const sagId = row.dataset.sagId;
|
|
if (sagId) {
|
|
const children = document.querySelectorAll(`tr[data-parent="${sagId}"]`);
|
|
children.forEach(child => {
|
|
const childText = child.textContent.toLowerCase();
|
|
const childStatus = String(child.dataset.status || '').toLowerCase();
|
|
const childType = child.dataset.type || 'ticket';
|
|
const childAssigneeRaw = String(child.dataset.assigneeId || '').trim();
|
|
const childGroupRaw = String(child.dataset.groupId || '').trim();
|
|
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
|
|
const childGroupId = childGroupRaw;
|
|
const childMatchesSearch = search.split(/\s+/).filter(Boolean).every(term => childText.includes(term));
|
|
const childIsClosed = closedStatuses.has(childStatus);
|
|
const childMatchesFilter = currentFilter === 'all'
|
|
|| (currentFilter === 'åben' && !childIsClosed)
|
|
|| (currentFilter === 'lukket' && childIsClosed)
|
|
|| childStatus === currentFilter;
|
|
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
|
|
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
|
|
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
|
|
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup && matchesQuickFilter(child, childIsClosed);
|
|
child.style.display = childVisible ? '' : 'none';
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderMiniFilterOptions(selectEl, listEl, selectedSet, labelEl, defaultLabel) {
|
|
if (!selectEl || !listEl) return;
|
|
const options = Array.from(selectEl.options || []).filter((opt) => String(opt.value || '').trim() !== '');
|
|
if (options.length === 0) {
|
|
listEl.innerHTML = '<span class="type-filter-empty">Ingen muligheder</span>';
|
|
if (labelEl) labelEl.textContent = defaultLabel;
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = options.map((opt) => {
|
|
const value = String(opt.value || '').trim();
|
|
const isChecked = selectedSet.has(value);
|
|
const safeValue = value.replace(/"/g, '"');
|
|
const safeLabel = String(opt.textContent || value).replace(/"/g, '"');
|
|
const safeId = `${selectEl.id}-opt-${value.replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
|
return `
|
|
<label class="mini-filter-item" for="${safeId}">
|
|
<input class="form-check-input" type="checkbox" id="${safeId}" data-value="${safeValue}" ${isChecked ? 'checked' : ''}>
|
|
<span class="mini-filter-label">${safeLabel}</span>
|
|
</label>
|
|
`;
|
|
}).join('');
|
|
|
|
if (labelEl) {
|
|
labelEl.textContent = selectedSet.size === 0 ? defaultLabel : `${selectedSet.size} valgt`;
|
|
}
|
|
}
|
|
|
|
function syncMiniSelect(selectEl, selectedSet) {
|
|
if (!selectEl) return;
|
|
Array.from(selectEl.options || []).forEach((opt) => {
|
|
const value = String(opt.value || '').trim();
|
|
opt.selected = value !== '' && selectedSet.has(value);
|
|
});
|
|
}
|
|
|
|
if (assigneeFilterList && assigneeFilter) {
|
|
assigneeFilterList.addEventListener('change', function(event) {
|
|
const checkbox = event.target.closest('input[type="checkbox"][data-value]');
|
|
if (!checkbox) return;
|
|
const value = String(checkbox.dataset.value || '').trim();
|
|
if (!value) return;
|
|
if (checkbox.checked) currentAssignees.add(value);
|
|
else currentAssignees.delete(value);
|
|
syncMiniSelect(assigneeFilter, currentAssignees);
|
|
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
if (groupFilterList && groupFilter) {
|
|
groupFilterList.addEventListener('change', function(event) {
|
|
const checkbox = event.target.closest('input[type="checkbox"][data-value]');
|
|
if (!checkbox) return;
|
|
const value = String(checkbox.dataset.value || '').trim();
|
|
if (!value) return;
|
|
if (checkbox.checked) currentGroups.add(value);
|
|
else currentGroups.delete(value);
|
|
syncMiniSelect(groupFilter, currentGroups);
|
|
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
if (clearAssigneeFilterBtn) {
|
|
clearAssigneeFilterBtn.addEventListener('click', function() {
|
|
currentAssignees = new Set();
|
|
syncMiniSelect(assigneeFilter, currentAssignees);
|
|
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
if (clearGroupFilterBtn) {
|
|
clearGroupFilterBtn.addEventListener('click', function() {
|
|
currentGroups = new Set();
|
|
syncMiniSelect(groupFilter, currentGroups);
|
|
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
function initMiniFilterSelections() {
|
|
if (assigneeFilter) {
|
|
currentAssignees = new Set(
|
|
Array.from(assigneeFilter.selectedOptions || [])
|
|
.map((opt) => String(opt.value || '').trim())
|
|
.filter((value) => value)
|
|
);
|
|
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
|
|
}
|
|
|
|
if (groupFilter) {
|
|
currentGroups = new Set(
|
|
Array.from(groupFilter.selectedOptions || [])
|
|
.map((opt) => String(opt.value || '').trim())
|
|
.filter((value) => value)
|
|
);
|
|
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
|
|
}
|
|
}
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', function(e) {
|
|
currentSearch = e.target.value.trim().toLowerCase();
|
|
document.getElementById('clearSearchBtn')?.classList.toggle('d-none', !currentSearch);
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
document.getElementById('clearSearchBtn')?.addEventListener('click', () => {
|
|
searchInput.value = '';
|
|
currentSearch = '';
|
|
document.getElementById('clearSearchBtn')?.classList.add('d-none');
|
|
searchInput.focus();
|
|
applyFilters();
|
|
});
|
|
|
|
document.addEventListener('keydown', event => {
|
|
if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
|
|
event.preventDefault();
|
|
searchInput?.focus();
|
|
}
|
|
if (event.key === 'Escape' && document.activeElement === searchInput && searchInput.value) {
|
|
document.getElementById('clearSearchBtn')?.click();
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('.sag-quick-filter').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const quick = button.dataset.quickFilter || 'all';
|
|
const url = new URL(window.location.href);
|
|
if (quick === 'all') url.searchParams.delete('quick');
|
|
else url.searchParams.set('quick', quick);
|
|
url.searchParams.delete('status');
|
|
window.location.assign(url.toString());
|
|
});
|
|
});
|
|
|
|
async function loadQuickFilterContext() {
|
|
try {
|
|
const response = await fetch('/api/v1/sag/me/quick-filter-context', { credentials: 'include' });
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
currentEmployeeId = String(data.user_id || '');
|
|
currentEmployeeGroupIds = new Set((data.group_ids || []).map(String));
|
|
applyFilters();
|
|
} catch (error) {
|
|
console.error('Kunne ikke hente hurtigfilter-kontekst', error);
|
|
}
|
|
}
|
|
loadQuickFilterContext();
|
|
|
|
// Filter functionality
|
|
const filterPills = document.querySelectorAll('.filter-pill');
|
|
|
|
filterPills.forEach(pill => {
|
|
pill.addEventListener('click', function() {
|
|
// Update active state
|
|
filterPills.forEach(p => p.classList.remove('active'));
|
|
this.classList.add('active');
|
|
|
|
currentFilter = this.dataset.filter || 'all';
|
|
applyFilters();
|
|
});
|
|
});
|
|
|
|
const typeFilter = document.getElementById('typeFilter');
|
|
const typeFilterCheckboxList = document.getElementById('typeFilterCheckboxList');
|
|
const typeFilterDropdownLabel = document.getElementById('typeFilterDropdownLabel');
|
|
const typeFilterSelection = document.getElementById('typeFilterSelection');
|
|
const clearTypeFilterBtn = document.getElementById('clearTypeFilterBtn');
|
|
const saveTypeFilterDefaultBtn = document.getElementById('saveTypeFilterDefaultBtn');
|
|
|
|
function getSelectedTypesFromUi() {
|
|
return Array.from(currentTypes);
|
|
}
|
|
|
|
function renderTypeFilterOptions() {
|
|
if (!typeFilterCheckboxList || !typeFilter) return;
|
|
const options = Array.from(typeFilter.options || []);
|
|
if (options.length === 0) {
|
|
typeFilterCheckboxList.innerHTML = '<span class="type-filter-empty">Ingen typer fundet</span>';
|
|
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgængelige';
|
|
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Ingen typer';
|
|
return;
|
|
}
|
|
|
|
typeFilterCheckboxList.innerHTML = options.map((opt) => {
|
|
const value = String(opt.value || '').trim();
|
|
const key = value.toLowerCase();
|
|
const isActive = currentTypes.has(key);
|
|
const safeValue = value.replace(/"/g, '"');
|
|
const safeId = `type-filter-opt-${key.replace(/[^a-z0-9_-]+/g, '-')}`;
|
|
return `
|
|
<label class="type-filter-checkbox-item" for="${safeId}">
|
|
<input class="form-check-input" type="checkbox" id="${safeId}" data-type="${safeValue}" ${isActive ? 'checked' : ''}>
|
|
<span class="type-filter-checkbox-label">${value}</span>
|
|
</label>
|
|
`;
|
|
}).join('');
|
|
|
|
if (typeFilterSelection) {
|
|
const selectedLabels = options
|
|
.filter((opt) => currentTypes.has(String(opt.value || '').trim().toLowerCase()))
|
|
.map((opt) => String(opt.value || '').trim())
|
|
.filter(Boolean);
|
|
if (selectedLabels.length === 0) {
|
|
typeFilterSelection.textContent = 'Ingen typer valgt';
|
|
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Vælg typer';
|
|
} else {
|
|
typeFilterSelection.textContent = `Valgt: ${selectedLabels.join(', ')}`;
|
|
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = `${selectedLabels.length} valgt`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function applySelectedTypesToUi() {
|
|
if (!typeFilter) return;
|
|
Array.from(typeFilter.options || []).forEach((opt) => {
|
|
opt.selected = currentTypes.has(String(opt.value || '').trim().toLowerCase());
|
|
});
|
|
renderTypeFilterOptions();
|
|
}
|
|
|
|
if (typeFilterCheckboxList) {
|
|
typeFilterCheckboxList.addEventListener('change', function(event) {
|
|
const checkbox = event.target.closest('input[type="checkbox"][data-type]');
|
|
if (!checkbox) return;
|
|
const typeValue = String(checkbox.dataset.type || '').trim().toLowerCase();
|
|
if (!typeValue) return;
|
|
if (checkbox.checked) {
|
|
currentTypes.add(typeValue);
|
|
} else {
|
|
currentTypes.delete(typeValue);
|
|
}
|
|
applySelectedTypesToUi();
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
if (clearTypeFilterBtn) {
|
|
clearTypeFilterBtn.addEventListener('click', function() {
|
|
currentTypes = new Set();
|
|
applySelectedTypesToUi();
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
async function loadTypeFilters() {
|
|
if (!typeFilter) return;
|
|
try {
|
|
const rowTypes = new Set(Array.from(document.querySelectorAll('.tree-row[data-type], .tree-child[data-type]'))
|
|
.map((row) => (row.dataset.type || '').trim())
|
|
.filter(Boolean));
|
|
|
|
const res = await fetch('/api/v1/settings/case_types');
|
|
let configuredTypes = [];
|
|
if (res.ok) {
|
|
const setting = await res.json();
|
|
const types = JSON.parse(setting.value || '[]');
|
|
if (Array.isArray(types)) {
|
|
configuredTypes = types;
|
|
}
|
|
}
|
|
|
|
configuredTypes.forEach((t) => rowTypes.add(String(t || '').trim()));
|
|
const mergedTypes = Array.from(rowTypes).filter(Boolean).sort((a, b) => a.localeCompare(b, 'da'));
|
|
if (mergedTypes.length === 0) {
|
|
typeFilter.innerHTML = '';
|
|
renderTypeFilterOptions();
|
|
return;
|
|
}
|
|
|
|
typeFilter.innerHTML = mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
|
|
applySelectedTypesToUi();
|
|
} catch (err) {
|
|
console.error('Failed to load case types', err);
|
|
}
|
|
}
|
|
|
|
const SAG_COLUMN_DEFINITIONS = [
|
|
['id', 'SagsID'], ['company', 'Virksomhed'], ['contact', 'Kontakt'],
|
|
['description', 'Beskrivelse'], ['type', 'Type'], ['priority', 'Prioritet'],
|
|
['status', 'Status'], ['owner', 'Ansvarlig'], ['group', 'Gruppe/Level'],
|
|
['next_todo', 'Næste todo'], ['created', 'Oprettet'], ['start', 'Arbejdsstart'],
|
|
['deferred', 'Start senest'], ['deadline', 'Deadline']
|
|
];
|
|
const SAG_DEFAULT_COLUMN_ORDER = SAG_COLUMN_DEFINITIONS.map(([key]) => key);
|
|
let sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
|
|
let sagHiddenColumns = new Set();
|
|
|
|
function normalizeSagColumnOrder(order) {
|
|
const allowed = new Set(SAG_DEFAULT_COLUMN_ORDER);
|
|
const normalized = Array.from(new Set((Array.isArray(order) ? order : []).filter(key => allowed.has(key))));
|
|
SAG_DEFAULT_COLUMN_ORDER.forEach(key => { if (!normalized.includes(key)) normalized.push(key); });
|
|
return normalized;
|
|
}
|
|
|
|
function applySagColumnPreferences() {
|
|
document.querySelectorAll('.sag-table tr').forEach(row => {
|
|
const cells = Array.from(row.children).filter(cell => cell.matches('th,td'));
|
|
if (!cells.length) return;
|
|
const expandCell = cells.find(cell => cell.dataset.columnKey === 'expand') || cells[0];
|
|
if (!expandCell.dataset.columnKey) {
|
|
expandCell.dataset.columnKey = 'expand';
|
|
cells.slice(1).forEach((cell, index) => {
|
|
if (SAG_DEFAULT_COLUMN_ORDER[index]) cell.dataset.columnKey = SAG_DEFAULT_COLUMN_ORDER[index];
|
|
});
|
|
}
|
|
const byKey = new Map(Array.from(row.children).filter(cell => cell.dataset.columnKey).map(cell => [cell.dataset.columnKey, cell]));
|
|
if (byKey.get('expand')) row.appendChild(byKey.get('expand'));
|
|
sagColumnOrder.forEach(key => {
|
|
const cell = byKey.get(key);
|
|
if (!cell) return;
|
|
cell.style.display = sagHiddenColumns.has(key) ? 'none' : '';
|
|
row.appendChild(cell);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderSagColumnChooser() {
|
|
const host = document.getElementById('sagColumnList');
|
|
if (!host) return;
|
|
const labels = Object.fromEntries(SAG_COLUMN_DEFINITIONS);
|
|
host.innerHTML = sagColumnOrder.map(key => `
|
|
<div class="sag-column-item" draggable="true" data-column-key="${key}">
|
|
<i class="bi bi-grip-vertical drag-handle"></i>
|
|
<input class="form-check-input sag-column-visible" type="checkbox" ${sagHiddenColumns.has(key) ? '' : 'checked'}>
|
|
<span>${labels[key]}</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function applySagColumnPayload(data) {
|
|
sagColumnOrder = normalizeSagColumnOrder(data?.column_order);
|
|
sagHiddenColumns = new Set((Array.isArray(data?.hidden_columns) ? data.hidden_columns : []).filter(key => SAG_DEFAULT_COLUMN_ORDER.includes(key)));
|
|
renderSagColumnChooser();
|
|
applySagColumnPreferences();
|
|
}
|
|
|
|
async function saveSagColumnPreferences() {
|
|
const button = document.getElementById('saveSagColumnsBtn');
|
|
if (button) button.disabled = true;
|
|
try {
|
|
const response = await fetch('/api/v1/sag/me/list-preferences', {
|
|
method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
type_filters: getSelectedTypesFromUi(),
|
|
column_order: sagColumnOrder,
|
|
hidden_columns: Array.from(sagHiddenColumns)
|
|
})
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.detail || 'Kolonnerne kunne ikke gemmes');
|
|
applySagColumnPayload(payload);
|
|
if (typeof showNotification === 'function') showNotification('Kolonner gemt for din bruger', 'success');
|
|
} catch (error) {
|
|
if (typeof showNotification === 'function') showNotification(error.message, 'error');
|
|
} finally {
|
|
if (button) button.disabled = false;
|
|
}
|
|
}
|
|
|
|
const sagColumnList = document.getElementById('sagColumnList');
|
|
let draggedSagColumn = null;
|
|
sagColumnList?.addEventListener('change', event => {
|
|
const item = event.target.closest('.sag-column-item');
|
|
if (!item || !event.target.matches('.sag-column-visible')) return;
|
|
if (event.target.checked) sagHiddenColumns.delete(item.dataset.columnKey);
|
|
else sagHiddenColumns.add(item.dataset.columnKey);
|
|
applySagColumnPreferences();
|
|
});
|
|
sagColumnList?.addEventListener('dragstart', event => {
|
|
draggedSagColumn = event.target.closest('.sag-column-item')?.dataset.columnKey || null;
|
|
event.target.closest('.sag-column-item')?.classList.add('dragging');
|
|
});
|
|
sagColumnList?.addEventListener('dragend', event => event.target.closest('.sag-column-item')?.classList.remove('dragging'));
|
|
sagColumnList?.addEventListener('dragover', event => event.preventDefault());
|
|
sagColumnList?.addEventListener('drop', event => {
|
|
event.preventDefault();
|
|
const targetKey = event.target.closest('.sag-column-item')?.dataset.columnKey;
|
|
if (!draggedSagColumn || !targetKey || draggedSagColumn === targetKey) return;
|
|
sagColumnOrder = sagColumnOrder.filter(key => key !== draggedSagColumn);
|
|
sagColumnOrder.splice(sagColumnOrder.indexOf(targetKey), 0, draggedSagColumn);
|
|
draggedSagColumn = null;
|
|
renderSagColumnChooser();
|
|
applySagColumnPreferences();
|
|
});
|
|
document.getElementById('resetSagColumnsBtn')?.addEventListener('click', () => {
|
|
sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
|
|
sagHiddenColumns = new Set();
|
|
renderSagColumnChooser();
|
|
applySagColumnPreferences();
|
|
});
|
|
document.getElementById('saveSagColumnsBtn')?.addEventListener('click', saveSagColumnPreferences);
|
|
|
|
async function loadTypeFilterPreferences() {
|
|
try {
|
|
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
|
|
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
|
|
applySagColumnPayload(data);
|
|
applySelectedTypesToUi();
|
|
applyFilters();
|
|
} catch (err) {
|
|
console.error('Failed to load type filter preferences', err);
|
|
}
|
|
}
|
|
|
|
async function saveTypeFilterPreferences() {
|
|
if (!saveTypeFilterDefaultBtn) return;
|
|
const selected = getSelectedTypesFromUi();
|
|
saveTypeFilterDefaultBtn.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/v1/sag/me/list-preferences', {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type_filters: selected, column_order: sagColumnOrder, hidden_columns: Array.from(sagHiddenColumns) }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Typefilter gemt som standard', 'success');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save type filter preferences', err);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Kunne ikke gemme typefilter', 'error');
|
|
}
|
|
} finally {
|
|
saveTypeFilterDefaultBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
if (saveTypeFilterDefaultBtn) {
|
|
saveTypeFilterDefaultBtn.addEventListener('click', saveTypeFilterPreferences);
|
|
}
|
|
|
|
(async () => {
|
|
initMiniFilterSelections();
|
|
await loadTypeFilters();
|
|
await loadTypeFilterPreferences();
|
|
applyFilters();
|
|
})();
|
|
|
|
if (topAlertCustomerId) {
|
|
loadSagTopAlertsForCustomer(topAlertCustomerId);
|
|
}
|
|
</script>
|
|
{% endblock %}
|