16219 lines
738 KiB
HTML
16219 lines
738 KiB
HTML
|
|
{% extends "shared/frontend/base.html" %}
|
|||
|
|
|
|||
|
|
{% block title %}{{ case.titel }} - BMC Hub{% endblock %}
|
|||
|
|
|
|||
|
|
{% block extra_css %}
|
|||
|
|
<style>
|
|||
|
|
.time-v1-calendar-container {
|
|||
|
|
background: var(--bg-surface, #fff);
|
|||
|
|
border: 1px solid var(--border-color, #e0e0e0);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
margin-bottom: 2rem;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
|||
|
|
}
|
|||
|
|
.time-v1-calendar-header {
|
|||
|
|
background: var(--bg-element, #f8f9fa);
|
|||
|
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
|||
|
|
padding: 12px 20px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 1rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
.time-v1-calendar-grid {
|
|||
|
|
display: flex;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
.time-v1-time-axis {
|
|||
|
|
width: 60px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
border-right: 1px solid var(--border-color, #f0f0f0);
|
|||
|
|
position: relative;
|
|||
|
|
background: var(--bg-element, #fafafa);
|
|||
|
|
padding-top: 40px;
|
|||
|
|
}
|
|||
|
|
.time-v1-hour-marker {
|
|||
|
|
position: absolute;
|
|||
|
|
width: 100%;
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-col {
|
|||
|
|
width: 200px;
|
|||
|
|
max-width: 200px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
border-right: 1px solid var(--border-color, #f0f0f0);
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-col:last-child {
|
|||
|
|
border-right: none;
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-header {
|
|||
|
|
text-align: center;
|
|||
|
|
padding: 8px;
|
|||
|
|
height: 40px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
|||
|
|
background: var(--bg-element, #f8f9fa);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
position: sticky;
|
|||
|
|
z-index: 50;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-body {
|
|||
|
|
position: relative;
|
|||
|
|
height: 600px;
|
|||
|
|
background-image: linear-gradient(to bottom, transparent 59px, var(--border-color, #f0f0f0) 60px);
|
|||
|
|
background-size: 100% 60px;
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-block {
|
|||
|
|
position: absolute;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|||
|
|
transition: transform 0.2s, box-shadow 0.2s, z-index 0.2s;
|
|||
|
|
border-left: 4px solid var(--bs-secondary);
|
|||
|
|
background: var(--bg-surface, #fff);
|
|||
|
|
cursor: grab;
|
|||
|
|
z-index: 10;
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-block:active { cursor: grabbing; opacity: 0.9; }
|
|||
|
|
.time-v1-entry-block:hover {
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|||
|
|
z-index: 20;
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-pending { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.05) !important; }
|
|||
|
|
.time-v1-entry-godkendt { border-left-color: #2fb344; background: rgba(47, 179, 68, 0.05) !important; }
|
|||
|
|
.time-v1-entry-kladde { border-left-color: #6c757d; background: rgba(108, 117, 125, 0.05) !important; }
|
|||
|
|
.time-v1-entry-time {
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
margin-bottom: 2px;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-desc {
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
display: -webkit-box;
|
|||
|
|
-webkit-line-clamp: 2;
|
|||
|
|
-webkit-box-orient: vertical;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.time-v1-unplaced-container {
|
|||
|
|
padding: 12px 20px;
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
background: var(--bg-element);
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
.time-v1-unplaced-item {
|
|||
|
|
background: var(--bg-surface);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
padding: 4px 10px;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
.time-v1-unplaced-item[draggable="true"] {
|
|||
|
|
cursor: grab;
|
|||
|
|
}
|
|||
|
|
.time-v1-unplaced-item.dragging,
|
|||
|
|
.time-v1-entry-block.dragging {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-body.time-v1-drop-target {
|
|||
|
|
outline: 2px dashed rgba(15, 76, 117, 0.45);
|
|||
|
|
outline-offset: -4px;
|
|||
|
|
background-color: rgba(15, 76, 117, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.time-v1-calendar-container {
|
|||
|
|
background: var(--bg-surface, #fff);
|
|||
|
|
border: 1px solid var(--border-color, #e0e0e0);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
margin-bottom: 2rem;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
|||
|
|
}
|
|||
|
|
.time-v1-calendar-header {
|
|||
|
|
background: var(--bg-element, #f8f9fa);
|
|||
|
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
|||
|
|
padding: 12px 20px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 1rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
.time-v1-calendar-grid {
|
|||
|
|
display: flex;
|
|||
|
|
position: relative;
|
|||
|
|
overflow-x: auto;
|
|||
|
|
}
|
|||
|
|
.time-v1-time-axis {
|
|||
|
|
width: 60px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
border-right: 1px solid var(--border-color, #f0f0f0);
|
|||
|
|
position: relative;
|
|||
|
|
background: var(--bg-element, #fafafa);
|
|||
|
|
padding-top: 40px;
|
|||
|
|
}
|
|||
|
|
.time-v1-hour-marker {
|
|||
|
|
position: absolute;
|
|||
|
|
width: 100%;
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-col {
|
|||
|
|
width: 200px;
|
|||
|
|
max-width: 200px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
border-right: 1px solid var(--border-color, #f0f0f0);
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-col:last-child {
|
|||
|
|
border-right: none;
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-header {
|
|||
|
|
text-align: center;
|
|||
|
|
padding: 8px;
|
|||
|
|
height: 40px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
|||
|
|
background: var(--bg-element, #f8f9fa);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
position: sticky;
|
|||
|
|
top: 0;
|
|||
|
|
z-index: 50;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
.time-v1-tech-body {
|
|||
|
|
position: relative;
|
|||
|
|
height: 600px;
|
|||
|
|
background-image: linear-gradient(to bottom, transparent 59px, var(--border-color, #f0f0f0) 60px);
|
|||
|
|
background-size: 100% 60px;
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-block {
|
|||
|
|
position: absolute;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|||
|
|
transition: transform 0.2s, box-shadow 0.2s, z-index 0.2s;
|
|||
|
|
border-left: 4px solid var(--bs-secondary);
|
|||
|
|
background: var(--bg-surface, #fff);
|
|||
|
|
cursor: grab;
|
|||
|
|
z-index: 10;
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-block:active { cursor: grabbing; opacity: 0.9; }
|
|||
|
|
.time-v1-entry-block:hover {
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|||
|
|
z-index: 20;
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-pending { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.05) !important; }
|
|||
|
|
.time-v1-entry-godkendt { border-left-color: #2fb344; background: rgba(47, 179, 68, 0.05) !important; }
|
|||
|
|
.time-v1-entry-kladde { border-left-color: #6c757d; background: rgba(108, 117, 125, 0.05) !important; }
|
|||
|
|
.time-v1-entry-time {
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
margin-bottom: 2px;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
.time-v1-entry-desc {
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
display: -webkit-box;
|
|||
|
|
-webkit-line-clamp: 2;
|
|||
|
|
-webkit-box-orient: vertical;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.time-v1-unplaced-container {
|
|||
|
|
padding: 12px 20px;
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
background: var(--bg-element);
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
.time-v1-unplaced-item {
|
|||
|
|
background: var(--bg-surface);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
padding: 4px 10px;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-row {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 0.8rem 0;
|
|||
|
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-row:last-child {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-label {
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
min-width: 150px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-value {
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag {
|
|||
|
|
display: inline-block;
|
|||
|
|
background: var(--accent-light);
|
|||
|
|
color: var(--accent);
|
|||
|
|
padding: 0.4rem 0.8rem;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
margin-right: 0.5rem;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-card {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 1rem;
|
|||
|
|
background: var(--bg-body);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-info strong {
|
|||
|
|
display: block;
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.person-info small {
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-buttons {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 1rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
margin-top: 2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-delete {
|
|||
|
|
background-color: #e74c3c;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-delete:hover {
|
|||
|
|
background-color: #c0392b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form-control, .form-select {
|
|||
|
|
border-radius: 8px;
|
|||
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-closed {
|
|||
|
|
background-color: #e0e0e0;
|
|||
|
|
color: #666;
|
|||
|
|
text-decoration: line-through;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .tag-closed {
|
|||
|
|
background-color: #3a3a3a;
|
|||
|
|
color: #999;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-state-badge {
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
padding: 0.2rem 0.4rem;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
margin-left: 0.5rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-state-open {
|
|||
|
|
background: #d4edda;
|
|||
|
|
color: #155724;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-state-closed {
|
|||
|
|
background: #f8d7da;
|
|||
|
|
color: #721c24;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .tag-state-open {
|
|||
|
|
background: #1e4620;
|
|||
|
|
color: #7fd98d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .tag-state-closed {
|
|||
|
|
background: #5c2b2f;
|
|||
|
|
color: #f8a5ac;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ═══════════════ PREMIUM CASE HERO ═══════════════ */
|
|||
|
|
.case-hero {
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border-radius: 16px;
|
|||
|
|
overflow: visible;
|
|||
|
|
box-shadow:
|
|||
|
|
0 0 0 1px rgba(0,0,0,0.06),
|
|||
|
|
0 4px 6px -1px rgba(0,0,0,0.05),
|
|||
|
|
0 16px 32px -8px rgba(15,76,117,0.10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-hero-identity {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 0.75rem 1.5rem;
|
|||
|
|
background: linear-gradient(135deg, rgba(15,76,117,0.04) 0%, rgba(15,76,117,0.01) 100%);
|
|||
|
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
border-radius: 16px 16px 0 0;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-id-chip {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
font-size: 1.0rem;
|
|||
|
|
font-weight: 900;
|
|||
|
|
letter-spacing: -0.5px;
|
|||
|
|
color: var(--tcolor, #0f4c75);
|
|||
|
|
background: color-mix(in srgb, var(--tcolor, #0f4c75) 10%, transparent);
|
|||
|
|
border: 1.5px solid color-mix(in srgb, var(--tcolor, #0f4c75) 30%, transparent);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
padding: 0.2em 0.65em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-type-chip {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.3em;
|
|||
|
|
font-size: 0.73rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: 0.05em;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
color: var(--tcolor, #0f4c75);
|
|||
|
|
background: color-mix(in srgb, var(--tcolor, #0f4c75) 8%, transparent);
|
|||
|
|
border: 1px solid color-mix(in srgb, var(--tcolor, #0f4c75) 25%, transparent);
|
|||
|
|
border-radius: 999px;
|
|||
|
|
padding: 0.3em 0.8em;
|
|||
|
|
transition: all 0.15s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-type-chip:hover {
|
|||
|
|
background: color-mix(in srgb, var(--tcolor, #0f4c75) 18%, transparent);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-status-chip {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4em;
|
|||
|
|
font-size: 0.73rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: 0.04em;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
padding: 0.3em 0.85em;
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-status-chip.open {
|
|||
|
|
background: #dcfce7;
|
|||
|
|
color: #15803d;
|
|||
|
|
border-color: #86efac;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-status-chip.closed {
|
|||
|
|
background: #f1f5f9;
|
|||
|
|
color: #475569;
|
|||
|
|
border-color: #cbd5e1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-status-chip.open {
|
|||
|
|
background: rgba(21,128,61,0.15);
|
|||
|
|
color: #4ade80;
|
|||
|
|
border-color: rgba(74,222,128,0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-status-chip.closed {
|
|||
|
|
background: rgba(71,85,105,0.15);
|
|||
|
|
color: #94a3b8;
|
|||
|
|
border-color: rgba(148,163,184,0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-status-dot {
|
|||
|
|
width: 7px;
|
|||
|
|
height: 7px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: currentColor;
|
|||
|
|
opacity: 0.85;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
animation: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-status-chip.open .case-status-dot {
|
|||
|
|
background: #16a34a;
|
|||
|
|
box-shadow: 0 0 0 2px #dcfce7, 0 0 6px #16a34a80;
|
|||
|
|
animation: pulse-dot 2s ease-in-out infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes pulse-dot {
|
|||
|
|
0%, 100% { opacity: 1; }
|
|||
|
|
50% { opacity: 0.5; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-date-item {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.3em;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
opacity: 0.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-date-item i {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-date-sep {
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
opacity: 0.3;
|
|||
|
|
font-size: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-hero-meta {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: stretch;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
padding: 0 0.25rem;
|
|||
|
|
min-height: 70px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-cell {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 0.85rem 1.25rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
transition: background 0.12s;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-cell:hover {
|
|||
|
|
background: rgba(0,0,0,0.025);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-meta-cell:hover {
|
|||
|
|
background: rgba(255,255,255,0.04);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-divider {
|
|||
|
|
width: 1px;
|
|||
|
|
background: rgba(0,0,0,0.07);
|
|||
|
|
margin: 0.6rem 0;
|
|||
|
|
align-self: stretch;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-meta-divider {
|
|||
|
|
background: rgba(255,255,255,0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-label {
|
|||
|
|
font-size: 0.6rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.08em;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
opacity: 0.55;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.3em;
|
|||
|
|
margin-bottom: 3px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-label i {
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
opacity: 0.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-value {
|
|||
|
|
font-size: 0.88rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
white-space: nowrap;
|
|||
|
|
display: block;
|
|||
|
|
line-height: 1.3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-link {
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: color 0.12s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-link:hover {
|
|||
|
|
color: var(--accent) !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-meta-empty {
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
opacity: 0.5;
|
|||
|
|
font-style: italic;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-inline-select {
|
|||
|
|
border: 1px solid rgba(0,0,0,0.09);
|
|||
|
|
background: var(--bg-body);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
padding: 0.3rem 0.6rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
min-width: 120px;
|
|||
|
|
max-width: 160px;
|
|||
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|||
|
|
transition: border-color 0.12s, box-shadow 0.12s;
|
|||
|
|
appearance: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-inline-select:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: var(--accent);
|
|||
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-date-badge {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.35em;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
padding: 0.25em 0.75em;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
background: rgba(0,0,0,0.04);
|
|||
|
|
border: 1px solid rgba(0,0,0,0.09);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-date-badge.overdue {
|
|||
|
|
background: #fef2f2;
|
|||
|
|
border-color: #fca5a5;
|
|||
|
|
color: #b91c1c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-date-badge.deferred {
|
|||
|
|
background: #fffbeb;
|
|||
|
|
border-color: #fcd34d;
|
|||
|
|
color: #92400e;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-date-badge {
|
|||
|
|
background: rgba(255,255,255,0.05);
|
|||
|
|
border-color: rgba(255,255,255,0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-date-badge.overdue {
|
|||
|
|
background: rgba(185,28,28,0.2);
|
|||
|
|
border-color: rgba(252,165,165,0.3);
|
|||
|
|
color: #fca5a5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-date-badge.deferred {
|
|||
|
|
background: rgba(146,64,14,0.2);
|
|||
|
|
border-color: rgba(252,211,77,0.3);
|
|||
|
|
color: #fcd34d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-edit-btn {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 24px;
|
|||
|
|
height: 24px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|||
|
|
background: transparent;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
opacity: 0.5;
|
|||
|
|
transition: all 0.12s;
|
|||
|
|
cursor: pointer;
|
|||
|
|
padding: 0;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-edit-btn:hover {
|
|||
|
|
opacity: 1;
|
|||
|
|
background: var(--bg-body);
|
|||
|
|
border-color: var(--accent);
|
|||
|
|
color: var(--accent);
|
|||
|
|
transform: scale(1.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hero-meta-label {
|
|||
|
|
font-size: 0.62rem;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.07em;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
opacity: 0.6;
|
|||
|
|
margin-bottom: 3px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hero-meta-value {
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
display: block;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hero-meta-value:hover {
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .hero-meta-label {
|
|||
|
|
color: rgba(255,255,255,0.45);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-summary-card {
|
|||
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-summary-header {
|
|||
|
|
background: linear-gradient(135deg, rgba(15, 76, 117, 0.12), rgba(15, 76, 117, 0.02));
|
|||
|
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
|||
|
|
padding: 0.85rem 1rem;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-summary-title {
|
|||
|
|
font-size: 1.15rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--accent);
|
|||
|
|
margin-bottom: 0.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-summary-meta {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-pill {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.25rem;
|
|||
|
|
padding: 0.2rem 0.6rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
background: rgba(15, 76, 117, 0.12);
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-pill-muted {
|
|||
|
|
background: rgba(0,0,0,0.06);
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-summary-body {
|
|||
|
|
padding: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-summary-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|||
|
|
gap: 0.75rem 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 992px) {
|
|||
|
|
.case-summary-grid {
|
|||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 576px) {
|
|||
|
|
.case-summary-grid {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-item {
|
|||
|
|
padding: 0.4rem 0.5rem;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: rgba(0,0,0,0.02);
|
|||
|
|
border: 1px solid rgba(0,0,0,0.04);
|
|||
|
|
min-height: 44px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-label {
|
|||
|
|
font-size: 0.62rem;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.04em;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
margin-bottom: 0.15rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-value {
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
word-break: break-word;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-link {
|
|||
|
|
color: var(--accent);
|
|||
|
|
cursor: pointer;
|
|||
|
|
text-decoration: underline;
|
|||
|
|
text-underline-offset: 3px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-summary-desc {
|
|||
|
|
margin-top: 0.9rem;
|
|||
|
|
padding: 0.8rem 0.9rem;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: rgba(15, 76, 117, 0.05);
|
|||
|
|
border: 1px dashed rgba(15, 76, 117, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.defer-controls {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
margin-top: 0.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.defer-controls .btn {
|
|||
|
|
padding: 0.15rem 0.45rem;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-inline {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.summary-inline .summary-pill {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.3rem;
|
|||
|
|
padding: 0.2rem 0.5rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
background: rgba(0,0,0,0.05);
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Forslag 1: Opgavebeskrivelse + kommentarspor (venstre side) */
|
|||
|
|
.narrative-description {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.22);
|
|||
|
|
background: linear-gradient(165deg, rgba(15, 76, 117, 0.11), rgba(15, 76, 117, 0.04));
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 1.1rem 1.1rem 1rem;
|
|||
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52), 0 3px 10px rgba(15, 76, 117, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#beskrivelse-section {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-left-section-title {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
font-weight: 800;
|
|||
|
|
letter-spacing: 0.06em;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
color: color-mix(in srgb, var(--accent) 68%, #2f3a45);
|
|||
|
|
margin-bottom: 0.55rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-left-section-title i {
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-left-panel {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.18);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 4%, var(--bg-card)), var(--bg-card));
|
|||
|
|
padding: 0.9rem;
|
|||
|
|
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#beskrivelse-history-wrap {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.18);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: color-mix(in srgb, var(--accent) 6%, var(--bg-card));
|
|||
|
|
padding: 0.75rem 0.85rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#beskrivelse-history-wrap .btn-link {
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 0.86rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-toggle-btn {
|
|||
|
|
width: 100%;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 0.6rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#beskrivelse-history-collapse {
|
|||
|
|
max-height: 260px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#beskrivelse-comments-wrap {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.2);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 3%, var(--bg-card)), var(--bg-card));
|
|||
|
|
padding: 0.95rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.narrative-lead {
|
|||
|
|
color: var(--accent);
|
|||
|
|
font-size: 1.1rem;
|
|||
|
|
font-weight: 800;
|
|||
|
|
margin-bottom: 0.55rem;
|
|||
|
|
line-height: 1.3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#beskrivelse-text {
|
|||
|
|
color: var(--text-primary) !important;
|
|||
|
|
font-size: 1rem !important;
|
|||
|
|
line-height: 1.7 !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-thread {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.2);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 0.65rem;
|
|||
|
|
max-height: 360px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
background: color-mix(in srgb, var(--accent) 3%, var(--bg-body));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-toolbar {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
|
|||
|
|
padding: 0.45rem 0.55rem;
|
|||
|
|
margin-bottom: 0.6rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.55rem;
|
|||
|
|
flex-wrap: nowrap;
|
|||
|
|
overflow-x: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-filter-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.8rem;
|
|||
|
|
flex-wrap: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-title-inline {
|
|||
|
|
margin: 0;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
font-size: 0.74rem;
|
|||
|
|
letter-spacing: 0.05em;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
font-weight: 800;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-toolbar-search {
|
|||
|
|
min-width: 170px;
|
|||
|
|
max-width: 240px;
|
|||
|
|
flex: 0 0 220px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-filter-group-label {
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: 0.04em;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
margin-right: 0.15rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-category-check {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
font-size: 0.74rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-category-check input {
|
|||
|
|
margin-top: 0;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-category-check .count {
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 992px) {
|
|||
|
|
.comment-toolbar {
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
overflow-x: visible;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-toolbar-search {
|
|||
|
|
min-width: 0;
|
|||
|
|
max-width: none;
|
|||
|
|
flex: 1 1 100%;
|
|||
|
|
order: 2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-filter-group-label {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-item {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.16);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
padding: 0.55rem 0.65rem;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
box-shadow: 0 1px 4px rgba(15, 76, 117, 0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-item:last-child {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-meta {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
margin-bottom: 0.25rem;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-meta b {
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-category-badge {
|
|||
|
|
font-size: 0.67rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: 0.02em;
|
|||
|
|
padding: 0.08rem 0.38rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-category-badge.category-system {
|
|||
|
|
background: rgba(111, 127, 142, 0.15);
|
|||
|
|
color: #44505b;
|
|||
|
|
border-color: rgba(111, 127, 142, 0.32);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-category-badge.category-internal {
|
|||
|
|
background: rgba(47, 122, 168, 0.12);
|
|||
|
|
color: #2f7aa8;
|
|||
|
|
border-color: rgba(47, 122, 168, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-category-badge.category-external {
|
|||
|
|
background: rgba(15, 76, 117, 0.12);
|
|||
|
|
color: #0f4c75;
|
|||
|
|
border-color: rgba(15, 76, 117, 0.28);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-time {
|
|||
|
|
margin-left: auto;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-avatar {
|
|||
|
|
width: 22px;
|
|||
|
|
height: 22px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-size: 0.63rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #fff;
|
|||
|
|
background: var(--accent);
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-item.comment-internal .comment-avatar {
|
|||
|
|
background: #2f7aa8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-item.comment-system .comment-avatar {
|
|||
|
|
background: #6f7f8e;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-body {
|
|||
|
|
line-height: 1.45;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 0.89rem;
|
|||
|
|
display: -webkit-box;
|
|||
|
|
-webkit-line-clamp: 6;
|
|||
|
|
-webkit-box-orient: vertical;
|
|||
|
|
overflow: hidden;
|
|||
|
|
white-space: normal;
|
|||
|
|
word-break: break-word;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-actions {
|
|||
|
|
margin-top: 0.35rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-actions .btn-link {
|
|||
|
|
padding: 0;
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
text-decoration: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-quick-reply-box {
|
|||
|
|
margin-top: 0.75rem;
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.2);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
|
|||
|
|
padding: 0.6rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-empty-filter {
|
|||
|
|
border: 1px dashed rgba(15, 76, 117, 0.25);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 0.75rem;
|
|||
|
|
text-align: center;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
background: rgba(255, 255, 255, 0.5);
|
|||
|
|
margin-top: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-timeline-shell {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.18);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 3%, var(--bg-card)), var(--bg-card));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-timeline-toolbar {
|
|||
|
|
border-bottom: 1px solid rgba(15, 76, 117, 0.15);
|
|||
|
|
padding: 0.6rem 0.75rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.6rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-timeline-list {
|
|||
|
|
max-height: 68vh;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 0.65rem 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-timeline-item {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.15);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
padding: 0.55rem 0.65rem;
|
|||
|
|
margin-bottom: 0.55rem;
|
|||
|
|
box-shadow: 0 1px 4px rgba(15, 76, 117, 0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-timeline-item.field-change {
|
|||
|
|
border-color: rgba(15, 76, 117, 0.35);
|
|||
|
|
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 8%, var(--bg-card)), var(--bg-card));
|
|||
|
|
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-badge-field-change {
|
|||
|
|
background: color-mix(in srgb, var(--accent) 16%, #ffffff);
|
|||
|
|
color: color-mix(in srgb, var(--accent) 84%, #000000);
|
|||
|
|
border: 1px solid color-mix(in srgb, var(--accent) 42%, #ffffff);
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-timeline-item:last-child {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-item-meta {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
margin-bottom: 0.2rem;
|
|||
|
|
font-size: 0.76rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-item-title {
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
margin-bottom: 0.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.history-item-body {
|
|||
|
|
font-size: 0.86rem;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
line-height: 1.4;
|
|||
|
|
white-space: pre-wrap;
|
|||
|
|
word-break: break-word;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .history-timeline-shell {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.28);
|
|||
|
|
background: linear-gradient(180deg, rgba(117, 194, 239, 0.08), rgba(17, 24, 33, 0.94));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .history-timeline-toolbar {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.24);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .history-timeline-item {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.24);
|
|||
|
|
background: rgba(20, 30, 41, 0.86);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .history-timeline-item.field-change {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.52);
|
|||
|
|
background: linear-gradient(180deg, rgba(117, 194, 239, 0.16), rgba(20, 30, 41, 0.92));
|
|||
|
|
box-shadow: 0 2px 10px rgba(117, 194, 239, 0.18);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .history-badge-field-change {
|
|||
|
|
background: rgba(117, 194, 239, 0.2);
|
|||
|
|
color: #d8efff;
|
|||
|
|
border-color: rgba(117, 194, 239, 0.45);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-quick-reply-actions {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
margin-top: 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-quick-reply-status {
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-email-header {
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-email-text {
|
|||
|
|
margin-top: 0.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-composer {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
align-items: stretch;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-composer textarea {
|
|||
|
|
border-radius: 10px;
|
|||
|
|
min-height: 68px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-send {
|
|||
|
|
border-radius: 10px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
min-width: 96px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.email-column-shell {
|
|||
|
|
border: 1px solid var(--border-color, #dbe3ea);
|
|||
|
|
border-radius: 14px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 250, 253, 0.96));
|
|||
|
|
box-shadow: 0 6px 20px rgba(15, 76, 117, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.email-column-shell .column-header {
|
|||
|
|
background: linear-gradient(90deg, rgba(15, 76, 117, 0.08), rgba(15, 76, 117, 0.02));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.email-thread-item .participants-line {
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.email-thread-item.active .participants-line {
|
|||
|
|
color: rgba(255, 255, 255, 0.82);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mail-read-chip {
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
letter-spacing: 0.02em;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.email-tab-unread-badge {
|
|||
|
|
display: none;
|
|||
|
|
margin-left: 0.45rem;
|
|||
|
|
min-width: 1.35rem;
|
|||
|
|
height: 1.35rem;
|
|||
|
|
padding: 0 0.38rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
line-height: 1.35rem;
|
|||
|
|
text-align: center;
|
|||
|
|
background: #2f9e44;
|
|||
|
|
color: #fff;
|
|||
|
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.92);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.email-tab-unread-badge.is-warm {
|
|||
|
|
background: #f08c00;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.email-tab-unread-badge.is-hot {
|
|||
|
|
background: #c92a2a;
|
|||
|
|
animation: unreadPulse 1.6s ease-in-out infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes unreadPulse {
|
|||
|
|
0% { transform: scale(1); }
|
|||
|
|
50% { transform: scale(1.09); }
|
|||
|
|
100% { transform: scale(1); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .email-column-shell {
|
|||
|
|
background: linear-gradient(180deg, rgba(19, 28, 38, 0.96), rgba(17, 24, 33, 0.96));
|
|||
|
|
border-color: rgba(117, 194, 239, 0.2);
|
|||
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .email-column-shell .column-header {
|
|||
|
|
background: linear-gradient(90deg, rgba(117, 194, 239, 0.16), rgba(117, 194, 239, 0.05));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .email-tab-unread-badge {
|
|||
|
|
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.32);
|
|||
|
|
background: linear-gradient(180deg, rgba(117, 194, 239, 0.2), rgba(117, 194, 239, 0.08));
|
|||
|
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 4px 12px rgba(5, 18, 30, 0.28);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-left-section-title {
|
|||
|
|
color: #d4e8f8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-left-section-title i {
|
|||
|
|
color: #8fc3e8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-left-panel,
|
|||
|
|
[data-bs-theme="dark"] #beskrivelse-history-wrap,
|
|||
|
|
[data-bs-theme="dark"] #beskrivelse-comments-wrap {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.28);
|
|||
|
|
background: linear-gradient(180deg, rgba(117, 194, 239, 0.08), rgba(17, 24, 33, 0.94));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .comment-thread,
|
|||
|
|
[data-bs-theme="dark"] .comment-item,
|
|||
|
|
[data-bs-theme="dark"] .comment-quick-reply-box {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.24);
|
|||
|
|
background: rgba(20, 30, 41, 0.86);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .comment-toolbar {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.28);
|
|||
|
|
background: rgba(27, 40, 54, 0.85);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .comment-empty-filter {
|
|||
|
|
border-color: rgba(117, 194, 239, 0.3);
|
|||
|
|
background: rgba(20, 30, 41, 0.9);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .comment-category-badge.category-system {
|
|||
|
|
color: #c9d4df;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .comment-category-badge.category-internal {
|
|||
|
|
color: #93c8ea;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .comment-category-badge.category-external {
|
|||
|
|
color: #9ecdeb;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card[data-module].module-empty-compact {
|
|||
|
|
height: auto !important;
|
|||
|
|
min-height: 0;
|
|||
|
|
--module-compact-min-height: 48px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card[data-module].module-empty-compact .card-body {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card[data-module].module-empty-compact .card-header,
|
|||
|
|
.card[data-module].module-empty-compact .module-header {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
padding-top: 0.45rem;
|
|||
|
|
padding-bottom: 0.45rem;
|
|||
|
|
min-height: var(--module-compact-min-height);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card[data-module].module-empty-compact .card-title,
|
|||
|
|
.card[data-module].module-empty-compact h5,
|
|||
|
|
.card[data-module].module-empty-compact h6 {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card[data-module].module-empty-compact .btn {
|
|||
|
|
--bs-btn-padding-y: 0.2rem;
|
|||
|
|
--bs-btn-padding-x: 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-section-header {
|
|||
|
|
padding: 0.28rem 0.55rem;
|
|||
|
|
font-size: 0.66rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.03em;
|
|||
|
|
background: rgba(15, 76, 117, 0.06);
|
|||
|
|
border-top: 1px solid rgba(15, 76, 117, 0.08);
|
|||
|
|
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#caseTabsContent > .tab-pane {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#caseTabsContent > .tab-pane.active,
|
|||
|
|
#caseTabsContent > .tab-pane.show.active {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-item {
|
|||
|
|
padding: 0.38rem 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-left {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-right {
|
|||
|
|
margin-left: auto;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
text-align: right;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-title {
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
line-height: 1.2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-meta {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.2rem;
|
|||
|
|
margin-top: 0.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-meta .meta-pill {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 0.08rem 0.34rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: rgba(0,0,0,0.05);
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-size: 0.64rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.25rem;
|
|||
|
|
margin-top: 0.3rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-right .todo-step-actions {
|
|||
|
|
margin-top: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-info-btn {
|
|||
|
|
width: 18px;
|
|||
|
|
height: 18px;
|
|||
|
|
padding: 0;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
font-size: 0.65rem;
|
|||
|
|
line-height: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.todo-step-actions .btn {
|
|||
|
|
--bs-btn-padding-y: 0.1rem;
|
|||
|
|
--bs-btn-padding-x: 0.28rem;
|
|||
|
|
--bs-btn-font-size: 0.68rem;
|
|||
|
|
line-height: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-tree {
|
|||
|
|
list-style: none;
|
|||
|
|
margin: 0;
|
|||
|
|
padding-left: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Sub-trees need indentation */
|
|||
|
|
.relation-children .relation-tree {
|
|||
|
|
margin-left: 8px; /* Indent children */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-node {
|
|||
|
|
position: relative;
|
|||
|
|
padding-left: 24px; /* Space for the connector */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Vertical Line (Spine) */
|
|||
|
|
.relation-children {
|
|||
|
|
/* This container wraps the child <ul> */
|
|||
|
|
position: relative;
|
|||
|
|
margin-left: 8px; /* Align with parent connector start */
|
|||
|
|
border-left: 1px solid rgba(15, 76, 117, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Horizontal Line (Connector) */
|
|||
|
|
.relation-node:before {
|
|||
|
|
content: "";
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 1.1rem; /* Mid-height of the top row (approx 32px/2 + padding) */
|
|||
|
|
width: 20px;
|
|||
|
|
height: 1px;
|
|||
|
|
background: rgba(15, 76, 117, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Fix: Last child should stop drawing the vertical line if we used ul border,
|
|||
|
|
but here we use .relation-children border which covers all.
|
|||
|
|
To get the "L" shape for the last child, we need the vertical line to come from the ITEM, not the LIST.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
/* Reset simpler approach: Stifinder style */
|
|||
|
|
.relation-tree, .relation-children { border: none !important; margin: 0; padding: 0; }
|
|||
|
|
|
|||
|
|
.relation-node {
|
|||
|
|
position: relative;
|
|||
|
|
padding-left: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Vertical line up to this node */
|
|||
|
|
.relation-node::before {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
top: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
left: 0;
|
|||
|
|
border-left: 1px solid rgba(15, 76, 117, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Horizontal link */
|
|||
|
|
.relation-node::after {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
top: 18px; /* Half of row height approx */
|
|||
|
|
left: 0;
|
|||
|
|
width: 20px;
|
|||
|
|
height: 1px;
|
|||
|
|
border-top: 1px solid rgba(15, 76, 117, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Remove vertical line for the last item, but keep the top half to form "L" */
|
|||
|
|
.relation-node:last-child::before {
|
|||
|
|
height: 18px; /* connectors height */
|
|||
|
|
bottom: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Root node shouldn't have lines if it's top level?
|
|||
|
|
Our macro recursively renders, so top level nodes are also .relation-node.
|
|||
|
|
We might need a wrapper. */
|
|||
|
|
.relation-tree > .relation-node:first-child::before,
|
|||
|
|
.relation-tree > .relation-node:first-child::after {
|
|||
|
|
/* If it's the absolute root, maybe no lines? depends on if we show multiple roots */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-children {
|
|||
|
|
margin-left: 24px; /* Indent for next level */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
.relation-node-card {
|
|||
|
|
background: rgba(15, 76, 117, 0.03);
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.12);
|
|||
|
|
transition: background 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-node-card:hover {
|
|||
|
|
background: rgba(15, 76, 117, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Relation row quick actions ────────────────────────────────────────── */
|
|||
|
|
.rel-row-actions {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.2rem;
|
|||
|
|
margin-left: auto;
|
|||
|
|
padding-left: 0.35rem;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.rel-row-actions .btn-rel-action {
|
|||
|
|
padding: 2px 6px;
|
|||
|
|
line-height: 1;
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
background: transparent;
|
|||
|
|
color: var(--text-secondary, #6c757d);
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
opacity: 1;
|
|||
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-radius: 5px;
|
|||
|
|
}
|
|||
|
|
.btn-rel-action:hover,
|
|||
|
|
.btn-rel-action:focus,
|
|||
|
|
.btn-rel-action.active {
|
|||
|
|
background: rgba(15,76,117,0.1);
|
|||
|
|
border-color: rgba(15,76,117,0.2);
|
|||
|
|
color: var(--accent);
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
.btn-rel-action:hover {
|
|||
|
|
opacity: 1 !important;
|
|||
|
|
background: rgba(15,76,117,0.1);
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* tag pills inside relation rows */
|
|||
|
|
.rel-tag-row {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 3px;
|
|||
|
|
padding: 2px 4px 3px 4px;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
.rel-tag-pill {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 3px;
|
|||
|
|
padding: 1px 7px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: rgba(15,76,117,0.09);
|
|||
|
|
border: 1px solid rgba(15,76,117,0.18);
|
|||
|
|
color: var(--accent);
|
|||
|
|
font-size: 0.68rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
cursor: default;
|
|||
|
|
}
|
|||
|
|
.rel-tag-pill .rel-tag-del {
|
|||
|
|
cursor: pointer;
|
|||
|
|
opacity: 0.5;
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
transition: opacity .15s;
|
|||
|
|
padding: 0 1px;
|
|||
|
|
}
|
|||
|
|
.rel-tag-pill .rel-tag-del:hover { opacity: 1; color: #dc3545; }
|
|||
|
|
.rel-tag-overflow {
|
|||
|
|
font-size: 0.68rem;
|
|||
|
|
color: var(--text-secondary, #6c757d);
|
|||
|
|
cursor: default;
|
|||
|
|
align-self: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* tag popover */
|
|||
|
|
.rel-tag-popover {
|
|||
|
|
position: absolute;
|
|||
|
|
z-index: 1080;
|
|||
|
|
background: var(--bg-card, #fff);
|
|||
|
|
border: 1px solid rgba(15,76,117,0.2);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
|
|||
|
|
min-width: 220px;
|
|||
|
|
max-width: 280px;
|
|||
|
|
padding: 0.5rem;
|
|||
|
|
}
|
|||
|
|
.rel-tag-popover input {
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
}
|
|||
|
|
.rel-tag-popover .rel-tag-suggestion {
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
border-radius: 5px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: background .12s;
|
|||
|
|
}
|
|||
|
|
.rel-tag-popover .rel-tag-suggestion:hover {
|
|||
|
|
background: rgba(15,76,117,0.09);
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
.rel-tag-popover .rel-tag-suggestion.new-tag {
|
|||
|
|
color: #198754;
|
|||
|
|
font-style: italic;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* quick-action dropdown */
|
|||
|
|
.rel-qa-menu {
|
|||
|
|
position: absolute;
|
|||
|
|
z-index: 1080;
|
|||
|
|
background: var(--bg-card, #fff);
|
|||
|
|
border: 1px solid rgba(15,76,117,0.2);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
|
|||
|
|
min-width: 185px;
|
|||
|
|
padding: 0.35rem 0;
|
|||
|
|
}
|
|||
|
|
.rel-qa-menu .qa-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.55rem;
|
|||
|
|
padding: 0.42rem 0.9rem;
|
|||
|
|
font-size: 0.84rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: background .12s;
|
|||
|
|
border-radius: 5px;
|
|||
|
|
margin: 0 0.2rem;
|
|||
|
|
}
|
|||
|
|
.rel-qa-menu .qa-item:hover {
|
|||
|
|
background: rgba(15,76,117,0.09);
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-type-pill {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.3rem;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
padding: 0.22rem 0.5rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
line-height: 1.2;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-type-pill.is-root {
|
|||
|
|
background: rgba(15, 76, 117, 0.12);
|
|||
|
|
color: #0f4c75;
|
|||
|
|
border-color: rgba(15, 76, 117, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-type-pill.is-related {
|
|||
|
|
background: rgba(100, 116, 139, 0.12);
|
|||
|
|
color: #334155;
|
|||
|
|
border-color: rgba(100, 116, 139, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-type-pill.is-derived {
|
|||
|
|
background: rgba(59, 130, 246, 0.12);
|
|||
|
|
color: #1d4ed8;
|
|||
|
|
border-color: rgba(59, 130, 246, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-type-pill.is-cause {
|
|||
|
|
background: rgba(16, 185, 129, 0.13);
|
|||
|
|
color: #047857;
|
|||
|
|
border-color: rgba(16, 185, 129, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-type-pill.is-block {
|
|||
|
|
background: rgba(239, 68, 68, 0.12);
|
|||
|
|
color: #b91c1c;
|
|||
|
|
border-color: rgba(239, 68, 68, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.relation-type-subtext {
|
|||
|
|
font-size: 0.68rem;
|
|||
|
|
color: var(--text-secondary, #6b7280);
|
|||
|
|
margin-top: 0.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.right-modules-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-priority-low {
|
|||
|
|
--module-accent: #64748b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-priority-normal {
|
|||
|
|
--module-accent: #0f4c75;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-priority-high {
|
|||
|
|
--module-accent: #d97706;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-priority-critical {
|
|||
|
|
--module-accent: #dc2626;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.right-module-card {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.14);
|
|||
|
|
border-left: 4px solid var(--module-accent, var(--accent));
|
|||
|
|
border-radius: 12px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, var(--accent)) 6%, var(--bg-card)) 0%, var(--bg-card) 100%);
|
|||
|
|
box-shadow: 0 4px 14px rgba(15, 76, 117, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.right-module-card .card-header {
|
|||
|
|
border-bottom: 1px solid color-mix(in srgb, var(--module-accent, var(--accent)) 22%, #d1d5db);
|
|||
|
|
background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-title {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
line-height: 1.2;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.module-icon {
|
|||
|
|
color: var(--module-accent, var(--accent));
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.right-modules-grid .card-header {
|
|||
|
|
padding: 0.35rem 0.6rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.right-modules-grid .card-header h6 {
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.right-modules-grid .card-body {
|
|||
|
|
padding: 0.4rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.contact-list-header,
|
|||
|
|
.contact-row {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 1.2fr 0.9fr 1fr auto;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.contact-list-header {
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.03em;
|
|||
|
|
color: #6c757d;
|
|||
|
|
padding: 0 0.25rem 0.25rem;
|
|||
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.contact-row {
|
|||
|
|
padding: 0.35rem 0.25rem;
|
|||
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.contact-row:last-child {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.contact-row .contact-name {
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.contact-row small {
|
|||
|
|
color: #6c757d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-list-header,
|
|||
|
|
.hardware-row,
|
|||
|
|
.location-list-header,
|
|||
|
|
.location-row,
|
|||
|
|
.customer-list-header,
|
|||
|
|
.customer-row {
|
|||
|
|
display: grid;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-list-header,
|
|||
|
|
.location-list-header,
|
|||
|
|
.customer-list-header {
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.03em;
|
|||
|
|
color: #6c757d;
|
|||
|
|
padding: 0 0.25rem 0.25rem;
|
|||
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-row,
|
|||
|
|
.location-row,
|
|||
|
|
.customer-row {
|
|||
|
|
padding: 0.35rem 0.25rem;
|
|||
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-row:last-child,
|
|||
|
|
.location-row:last-child,
|
|||
|
|
.customer-row:last-child {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-list-header,
|
|||
|
|
.hardware-row {
|
|||
|
|
grid-template-columns: minmax(0, 1.5fr) minmax(110px, 1fr) 56px 56px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-list-header span:nth-child(3),
|
|||
|
|
.hardware-list-header span:nth-child(4),
|
|||
|
|
.hardware-row > *:nth-child(3),
|
|||
|
|
.hardware-row > *:nth-child(4) {
|
|||
|
|
justify-self: end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-row > div:first-child {
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-row > div:first-child a {
|
|||
|
|
display: inline-block;
|
|||
|
|
max-width: 100%;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hardware-row small {
|
|||
|
|
min-width: 0;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.location-list-header,
|
|||
|
|
.location-row {
|
|||
|
|
grid-template-columns: 1.3fr 1fr auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customer-list-header,
|
|||
|
|
.customer-row {
|
|||
|
|
grid-template-columns: 1.2fr 0.9fr 1fr auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-toggle-btn {
|
|||
|
|
background: none;
|
|||
|
|
border: 1px solid rgba(0,0,0,0.2);
|
|||
|
|
padding: 0.2rem 0.5rem;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
margin-left: 0.5rem;
|
|||
|
|
transition: all 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-toggle-btn:hover {
|
|||
|
|
background: rgba(0,0,0,0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-toggle-open {
|
|||
|
|
color: #28a745;
|
|||
|
|
border-color: #28a745;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-toggle-open:hover {
|
|||
|
|
background: #28a745;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-toggle-closed {
|
|||
|
|
color: #6c757d;
|
|||
|
|
border-color: #6c757d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tag-toggle-closed:hover {
|
|||
|
|
background: #6c757d;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|||
|
|
gap: 0.7rem;
|
|||
|
|
align-items: start;
|
|||
|
|
background: transparent;
|
|||
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|||
|
|
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: minmax(86px, 0.55fr) minmax(220px, 1.7fr) repeat(6, minmax(120px, 1fr));
|
|||
|
|
background: #0f4c75;
|
|||
|
|
border: 1px solid #0b3a59;
|
|||
|
|
border-radius: 0;
|
|||
|
|
box-shadow: none;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-tabs-topbar-item {
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
border-left: 1px solid rgba(255,255,255,0.24);
|
|||
|
|
border-radius: 0;
|
|||
|
|
padding: 0.45rem 0.7rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-tabs-topbar-item:first-child {
|
|||
|
|
border-left: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-tabs-topbar-label {
|
|||
|
|
color: rgba(255,255,255,0.88);
|
|||
|
|
opacity: 1;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
letter-spacing: 0.1em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-tabs-topbar-value {
|
|||
|
|
font-size: 1.12rem;
|
|||
|
|
font-weight: 750;
|
|||
|
|
color: #ffffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .field-case-id .case-tabs-topbar-value {
|
|||
|
|
font-size: 0.96rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: 0.01em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .field-case-id {
|
|||
|
|
padding-right: 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-company-row {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.42rem;
|
|||
|
|
max-width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-company-name {
|
|||
|
|
min-width: 0;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .topbar-company-name {
|
|||
|
|
color: #ffffff !important;
|
|||
|
|
font-size: 1.18rem;
|
|||
|
|
font-weight: 800;
|
|||
|
|
line-height: 1.15;
|
|||
|
|
letter-spacing: 0.01em;
|
|||
|
|
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-company-contact {
|
|||
|
|
margin-top: 0.22rem;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
line-height: 1.2;
|
|||
|
|
color: rgba(255,255,255,0.88);
|
|||
|
|
opacity: 1;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-company-contact i {
|
|||
|
|
margin-right: 0.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-company-edit-btn {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 1.5rem;
|
|||
|
|
height: 1.5rem;
|
|||
|
|
padding: 0;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid rgba(15,76,117,0.35);
|
|||
|
|
background: #0d6efd;
|
|||
|
|
color: #ffffff;
|
|||
|
|
box-shadow: 0 1px 4px rgba(13,110,253,0.25);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-company-edit-btn:hover,
|
|||
|
|
.topbar-company-edit-btn:focus-visible {
|
|||
|
|
background: #0b5ed7;
|
|||
|
|
border-color: rgba(11,94,215,0.85);
|
|||
|
|
color: #ffffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-inline-select {
|
|||
|
|
background: rgba(255,255,255,0.14);
|
|||
|
|
border-color: rgba(255,255,255,0.36);
|
|||
|
|
box-shadow: none;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #ffffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .topbar-next-meta {
|
|||
|
|
margin-top: 0.12rem;
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
color: rgba(255,255,255,0.8);
|
|||
|
|
opacity: 1;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .topbar-secondary-action {
|
|||
|
|
border-color: rgba(255,255,255,0.36);
|
|||
|
|
background: rgba(255,255,255,0.12);
|
|||
|
|
color: #ffffff;
|
|||
|
|
font-size: 0.88rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .topbar-secondary-action:hover,
|
|||
|
|
.topbar-primary .topbar-secondary-action:focus-visible {
|
|||
|
|
background: rgba(255,255,255,0.22);
|
|||
|
|
color: #ffffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-tabs-topbar-label i,
|
|||
|
|
.topbar-primary .topbar-next-meta i,
|
|||
|
|
.topbar-primary .case-tabs-topbar-value i {
|
|||
|
|
color: rgba(255,255,255,0.9);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-link {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
font-size: 0.84rem;
|
|||
|
|
letter-spacing: 0.01em;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
text-decoration: none;
|
|||
|
|
padding: 0.38rem 0.1rem;
|
|||
|
|
min-height: 38px;
|
|||
|
|
border-radius: 0;
|
|||
|
|
border: none;
|
|||
|
|
background: transparent;
|
|||
|
|
box-shadow: none;
|
|||
|
|
transition: transform 0.14s ease, box-shadow 0.14s ease, filter 0.14s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-link:hover {
|
|||
|
|
color: var(--accent);
|
|||
|
|
background: transparent;
|
|||
|
|
text-decoration: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-link:focus-visible {
|
|||
|
|
outline: 3px solid color-mix(in srgb, var(--accent) 42%, #9dd3ff);
|
|||
|
|
outline-offset: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .back-link {
|
|||
|
|
color: #dce8f4;
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .back-link:hover {
|
|||
|
|
color: #9fc4e8;
|
|||
|
|
background: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-tabs-topbar.topbar-primary {
|
|||
|
|
background: #0f4c75;
|
|||
|
|
border-color: #75a7cc;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-primary .case-tabs-topbar-item {
|
|||
|
|
border-left-color: rgba(255,255,255,0.28);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-primary .case-tabs-topbar-label,
|
|||
|
|
[data-bs-theme="dark"] .topbar-primary .case-tabs-topbar-value {
|
|||
|
|
color: #ffffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-primary .case-inline-select {
|
|||
|
|
background: rgba(255,255,255,0.14);
|
|||
|
|
border-color: rgba(255,255,255,0.4);
|
|||
|
|
color: #ffffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card[data-module] {
|
|||
|
|
border-width: 2px !important;
|
|||
|
|
border-color: rgba(15, 76, 117, 0.28) !important;
|
|||
|
|
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .card[data-module] {
|
|||
|
|
border-color: rgba(117, 167, 204, 0.45) !important;
|
|||
|
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-company-edit-btn {
|
|||
|
|
border-color: rgba(170,205,245,0.5);
|
|||
|
|
box-shadow: 0 1px 6px rgba(75,145,255,0.35);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-company-contact {
|
|||
|
|
color: #b8d9f1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-primary .topbar-company-name {
|
|||
|
|
color: #ffffff !important;
|
|||
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .right-module-card {
|
|||
|
|
border-color: rgba(140, 182, 219, 0.25);
|
|||
|
|
box-shadow: 0 4px 16px rgba(5, 22, 40, 0.45);
|
|||
|
|
background: linear-gradient(165deg, color-mix(in srgb, var(--module-accent, #69a6d5) 12%, rgba(18, 28, 40, 0.94)) 0%, rgba(18, 28, 40, 0.94) 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .right-module-card .card-header {
|
|||
|
|
border-bottom-color: color-mix(in srgb, var(--module-accent, #69a6d5) 45%, #4b5563);
|
|||
|
|
background: color-mix(in srgb, var(--module-accent, #69a6d5) 18%, rgba(18, 28, 40, 0.98));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .module-title {
|
|||
|
|
color: #e5edf5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary {
|
|||
|
|
/* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
|
|||
|
|
grid-template-columns:
|
|||
|
|
minmax(105px, 0.75fr) /* Oprettet */
|
|||
|
|
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 */;
|
|||
|
|
align-items: stretch;
|
|||
|
|
background: linear-gradient(180deg, rgba(15, 76, 117, 0.10), rgba(15, 76, 117, 0.04));
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.30);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 0.42rem 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary .case-tabs-topbar-item {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
justify-content: flex-start;
|
|||
|
|
height: 100%;
|
|||
|
|
background: rgba(255, 255, 255, 0.82);
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.24);
|
|||
|
|
border-left: 1px solid rgba(15, 76, 117, 0.24);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary .case-tabs-topbar-item:first-child {
|
|||
|
|
border-left: 1px solid rgba(15, 76, 117, 0.24);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary .case-tabs-topbar-label {
|
|||
|
|
color: #1c425c;
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary .case-tabs-topbar-value {
|
|||
|
|
color: #0f2f45;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary .case-inline-select {
|
|||
|
|
background: #ffffff;
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.34);
|
|||
|
|
color: #0f2f45;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary .topbar-secondary-action {
|
|||
|
|
border-color: rgba(15, 76, 117, 0.34);
|
|||
|
|
background: #ffffff;
|
|||
|
|
color: #0f2f45;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary .topbar-secondary-action:hover,
|
|||
|
|
.case-tabs-topbar.topbar-secondary .topbar-secondary-action:focus-visible {
|
|||
|
|
background: rgba(15, 76, 117, 0.12);
|
|||
|
|
border-color: rgba(15, 76, 117, 0.55);
|
|||
|
|
color: #0b2535;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar-item {
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
border-left: 1px solid rgba(15,76,117,0.16);
|
|||
|
|
border-radius: 0;
|
|||
|
|
padding: 0.58rem 0.68rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar-item:first-child {
|
|||
|
|
border-left: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar-item:hover {
|
|||
|
|
border-color: rgba(15, 76, 117, 0.28);
|
|||
|
|
box-shadow: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar-label {
|
|||
|
|
font-size: 0.62rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.08em;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
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;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
line-height: 1.25;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar-value.multiline {
|
|||
|
|
white-space: normal;
|
|||
|
|
display: -webkit-box;
|
|||
|
|
-webkit-line-clamp: 2;
|
|||
|
|
-webkit-box-orient: vertical;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar .case-inline-select {
|
|||
|
|
width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
max-width: none;
|
|||
|
|
font-size: 0.86rem;
|
|||
|
|
padding: 0.45rem 0.7rem;
|
|||
|
|
min-height: 38px;
|
|||
|
|
border-radius: 0;
|
|||
|
|
box-shadow: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-top-aux {
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
min-width: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-top-aux .case-tabs-topbar {
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
padding: 0;
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-hero {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-secondary-action {
|
|||
|
|
border: 1px solid rgba(0,0,0,0.12);
|
|||
|
|
background: rgba(255,255,255,0.72);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
border-radius: 0;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
padding: 0.45rem 0.7rem;
|
|||
|
|
min-height: 38px;
|
|||
|
|
text-align: left;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: flex-start;
|
|||
|
|
gap: 0.3rem;
|
|||
|
|
line-height: 1.1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-secondary-action.is-wide {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-secondary-action.is-icon {
|
|||
|
|
width: auto;
|
|||
|
|
min-width: 38px;
|
|||
|
|
flex: 0 0 38px;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 0.45rem 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar .dropdown-menu {
|
|||
|
|
z-index: 1080;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-secondary-inline {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
flex-wrap: nowrap;
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-secondary-inline > * {
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-secondary-inline .case-inline-select[type="date"] {
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
min-width: 0;
|
|||
|
|
max-width: none;
|
|||
|
|
font-variant-numeric: tabular-nums;
|
|||
|
|
text-align: left;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
.topbar-deferred-current {
|
|||
|
|
margin-top: 0.4rem;
|
|||
|
|
padding: 0.3rem 0.45rem;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
line-height: 1.25;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
background: rgba(255, 255, 255, 0.52);
|
|||
|
|
border: 1px dashed rgba(0, 0, 0, 0.12);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.28rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-deferred-current.is-set {
|
|||
|
|
color: #0f4c75;
|
|||
|
|
border-style: solid;
|
|||
|
|
border-color: rgba(15, 76, 117, 0.28);
|
|||
|
|
background: rgba(15, 76, 117, 0.09);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
.topbar-secondary-action:hover {
|
|||
|
|
border-color: var(--accent);
|
|||
|
|
color: var(--accent);
|
|||
|
|
background: rgba(255,255,255,0.95);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-deferred-current {
|
|||
|
|
color: rgba(236, 242, 255, 0.82);
|
|||
|
|
background: rgba(255, 255, 255, 0.06);
|
|||
|
|
border-color: rgba(255, 255, 255, 0.16);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-deferred-current.is-set {
|
|||
|
|
color: #9dd7ff;
|
|||
|
|
border-color: rgba(108, 188, 255, 0.42);
|
|||
|
|
background: rgba(19, 100, 154, 0.24);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-secondary-action {
|
|||
|
|
background: rgba(20, 27, 38, 0.72);
|
|||
|
|
border-color: rgba(170, 190, 216, 0.35);
|
|||
|
|
color: #dce8f4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-secondary-action:hover {
|
|||
|
|
border-color: #9fc4e8;
|
|||
|
|
color: #9fc4e8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .topbar-secondary-action.is-icon {
|
|||
|
|
background: rgba(20, 27, 38, 0.78);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-tabs-topbar.topbar-secondary {
|
|||
|
|
background: linear-gradient(180deg, rgba(117, 194, 239, 0.18), rgba(117, 194, 239, 0.08));
|
|||
|
|
border-color: rgba(117, 194, 239, 0.36);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-tabs-topbar.topbar-secondary .case-tabs-topbar-item {
|
|||
|
|
background: rgba(15, 24, 34, 0.9);
|
|||
|
|
border-color: rgba(117, 194, 239, 0.32);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-tabs-topbar.topbar-secondary .case-tabs-topbar-label {
|
|||
|
|
color: #cfe6f7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-tabs-topbar.topbar-secondary .case-tabs-topbar-value {
|
|||
|
|
color: #f2f8fe;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-tabs-topbar.topbar-secondary .case-inline-select,
|
|||
|
|
[data-bs-theme="dark"] .case-tabs-topbar.topbar-secondary .topbar-secondary-action {
|
|||
|
|
background: rgba(19, 30, 42, 0.92);
|
|||
|
|
border-color: rgba(117, 194, 239, 0.38);
|
|||
|
|
color: #eef6fd;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
.case-add-side-backdrop {
|
|||
|
|
position: fixed;
|
|||
|
|
inset: 0;
|
|||
|
|
background: transparent;
|
|||
|
|
z-index: 1060;
|
|||
|
|
opacity: 0;
|
|||
|
|
pointer-events: none;
|
|||
|
|
transition: opacity 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-backdrop.open {
|
|||
|
|
opacity: 0;
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-panel {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
width: min(460px, 92vw);
|
|||
|
|
background: var(--bg-card, #ffffff);
|
|||
|
|
border-right: 1px solid rgba(15, 76, 117, 0.16);
|
|||
|
|
box-shadow: 8px 0 28px rgba(5, 16, 30, 0.2);
|
|||
|
|
z-index: 1061;
|
|||
|
|
transform: translateX(-105%);
|
|||
|
|
transition: transform 0.22s ease;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-panel.open {
|
|||
|
|
transform: translateX(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-reopen {
|
|||
|
|
position: fixed;
|
|||
|
|
left: 0;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
z-index: 1063;
|
|||
|
|
border: 1px solid #c97700;
|
|||
|
|
border-left: 0;
|
|||
|
|
border-radius: 0 8px 8px 0;
|
|||
|
|
background: #f59e0b;
|
|||
|
|
color: #ffffff;
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
padding: 0.42rem 0.55rem;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-reopen.show {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-header {
|
|||
|
|
padding: 0.8rem 0.95rem;
|
|||
|
|
border-bottom: 1px solid rgba(15, 76, 117, 0.14);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-title {
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
margin: 0;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-side-body {
|
|||
|
|
padding: 0.85rem;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 0.7rem;
|
|||
|
|
height: 100%;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-module-list {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-module-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-module-btn {
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
background: #f8fbff;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
padding: 0.4rem 0.5rem;
|
|||
|
|
text-align: left;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-module-btn.active {
|
|||
|
|
border-color: var(--accent);
|
|||
|
|
background: color-mix(in srgb, var(--accent) 14%, white);
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-module-toggle {
|
|||
|
|
width: 1rem;
|
|||
|
|
height: 1rem;
|
|||
|
|
margin: 0;
|
|||
|
|
cursor: pointer;
|
|||
|
|
accent-color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-workspace {
|
|||
|
|
border: 1px solid rgba(15, 76, 117, 0.14);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 0.8rem;
|
|||
|
|
background: #fbfdff;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-workspace .section-title {
|
|||
|
|
font-size: 0.86rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
margin-bottom: 0.55rem;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-add-workspace .list-group-item {
|
|||
|
|
border-radius: 6px;
|
|||
|
|
margin-bottom: 0.3rem;
|
|||
|
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-add-side-panel {
|
|||
|
|
background: #0f1723;
|
|||
|
|
border-right-color: rgba(166, 200, 235, 0.25);
|
|||
|
|
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-add-side-header {
|
|||
|
|
border-bottom-color: rgba(166, 200, 235, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-add-module-btn {
|
|||
|
|
background: rgba(18, 34, 53, 0.85);
|
|||
|
|
border-color: rgba(170, 190, 216, 0.25);
|
|||
|
|
color: #d8e5f3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-add-module-btn.active {
|
|||
|
|
background: rgba(44, 107, 162, 0.3);
|
|||
|
|
border-color: #8ec1ee;
|
|||
|
|
color: #b7d8f4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-add-module-toggle {
|
|||
|
|
accent-color: #8ec1ee;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-add-workspace {
|
|||
|
|
background: rgba(13, 24, 38, 0.9);
|
|||
|
|
border-color: rgba(166, 200, 235, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .case-add-side-reopen {
|
|||
|
|
background: #f59e0b;
|
|||
|
|
color: #1f1300;
|
|||
|
|
border-color: #ffbf4d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1200px) {
|
|||
|
|
.case-tabs-topbar {
|
|||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary {
|
|||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-tabs-topbar-item {
|
|||
|
|
border-left: none;
|
|||
|
|
border-top: 1px solid rgba(15,76,117,0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topbar-primary .case-tabs-topbar-item:nth-child(-n+2) {
|
|||
|
|
border-top: none;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
.case-tabs-topbar {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-tabs-topbar.topbar-secondary {
|
|||
|
|
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));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* V3 readability fix: keep menu structure unchanged, restore normal text sizing */
|
|||
|
|
#caseTabs .nav-link {
|
|||
|
|
font-size: 1rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#caseTabs .nav-link i {
|
|||
|
|
font-size: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-detail-page-shell .header-bg .badge {
|
|||
|
|
font-size: 0.95rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-detail-page-shell .card-body > .text-uppercase.fw-bold.text-muted.mb-3 {
|
|||
|
|
font-size: 0.86rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-detail-page-shell .card-body label {
|
|||
|
|
font-size: 0.95rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-detail-page-shell .form-select.form-select-sm,
|
|||
|
|
.case-detail-page-shell .input-group-sm > .form-control,
|
|||
|
|
.case-detail-page-shell .input-group-sm > .btn,
|
|||
|
|
.case-detail-page-shell #topbarNextTodoValue {
|
|||
|
|
font-size: 0.95rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* V3 top header compaction: better use of space without changing menu behavior */
|
|||
|
|
.case-detail-page-shell .header-bg {
|
|||
|
|
padding-top: 0.9rem !important;
|
|||
|
|
padding-bottom: 1.2rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-detail-page-shell .header-bg .container-fluid > .d-flex:first-child {
|
|||
|
|
margin-bottom: 0.5rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#sag-titel-view {
|
|||
|
|
margin-bottom: 0.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#sag-titel-text {
|
|||
|
|
font-size: clamp(1.9rem, 2.7vw, 2.45rem);
|
|||
|
|
line-height: 1.15;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-header-meta-line {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
color: rgba(255, 255, 255, 0.88) !important;
|
|||
|
|
font-size: 1rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-header-meta-line .meta-divider {
|
|||
|
|
opacity: 0.32;
|
|||
|
|
margin: 0 0.08rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-header-meta-line a {
|
|||
|
|
color: #fff;
|
|||
|
|
text-decoration: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-header-meta-line a:hover {
|
|||
|
|
text-decoration: underline;
|
|||
|
|
text-underline-offset: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-top-row {
|
|||
|
|
margin-top: 0.42rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-actions-cluster {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: row;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
flex-wrap: nowrap;
|
|||
|
|
width: min(700px, 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-action-btn {
|
|||
|
|
height: 34px;
|
|||
|
|
padding: 0.22rem 0.72rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
font-size: 0.84rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.34rem;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-actions-cluster .header-action-btn {
|
|||
|
|
border: 1px solid rgba(255, 255, 255, 0.34);
|
|||
|
|
background: rgba(255, 255, 255, 0.14);
|
|||
|
|
color: #ffffff;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-actions-cluster .header-action-btn:hover,
|
|||
|
|
.header-actions-cluster .header-action-btn:focus {
|
|||
|
|
border-color: rgba(255, 255, 255, 0.44);
|
|||
|
|
background: rgba(255, 255, 255, 0.22);
|
|||
|
|
color: #ffffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-actions-cluster .header-action-btn:focus-visible {
|
|||
|
|
box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.22);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-next-todo-chip {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.55rem;
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: none;
|
|||
|
|
min-width: 260px;
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
padding: 0.34rem 0.62rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: rgba(255, 255, 255, 0.14);
|
|||
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|||
|
|
color: #ffffff;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-next-todo-chip .todo-label {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.28rem;
|
|||
|
|
padding: 0.14rem 0.4rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: rgba(255, 255, 255, 0.22);
|
|||
|
|
border: 1px solid rgba(255, 255, 255, 0.34);
|
|||
|
|
font-size: 0.76rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: 0.02em;
|
|||
|
|
color: #ffffff;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-next-todo-chip #topbarNextTodoValue {
|
|||
|
|
color: #ffffff;
|
|||
|
|
font-size: 0.9rem !important;
|
|||
|
|
font-weight: 700;
|
|||
|
|
min-width: 0;
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-next-todo-chip #topbarNextTodoMeta {
|
|||
|
|
color: rgba(255, 255, 255, 0.86) !important;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
background: rgba(255, 255, 255, 0.18);
|
|||
|
|
padding: 0.1rem 0.4rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .header-next-todo-chip {
|
|||
|
|
background: rgba(255, 255, 255, 0.12);
|
|||
|
|
border-color: rgba(255, 255, 255, 0.3);
|
|||
|
|
color: #eef4fb;
|
|||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.32);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .header-actions-cluster .header-action-btn {
|
|||
|
|
background: rgba(255, 255, 255, 0.12);
|
|||
|
|
border-color: rgba(255, 255, 255, 0.3);
|
|||
|
|
color: #eef4fb;
|
|||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.32);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .header-actions-cluster .header-action-btn:hover,
|
|||
|
|
[data-bs-theme="dark"] .header-actions-cluster .header-action-btn:focus {
|
|||
|
|
background: rgba(255, 255, 255, 0.2);
|
|||
|
|
border-color: rgba(255, 255, 255, 0.4);
|
|||
|
|
color: #eef4fb;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .header-next-todo-chip .todo-label {
|
|||
|
|
background: rgba(255, 255, 255, 0.2);
|
|||
|
|
color: #eef4fb;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .header-next-todo-chip #topbarNextTodoValue {
|
|||
|
|
color: #eef4fb;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[data-bs-theme="dark"] .header-next-todo-chip #topbarNextTodoMeta {
|
|||
|
|
color: rgba(238, 244, 251, 0.85) !important;
|
|||
|
|
background: rgba(255, 255, 255, 0.14);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 992px) {
|
|||
|
|
.case-detail-page-shell .header-bg {
|
|||
|
|
padding-top: 0.8rem !important;
|
|||
|
|
padding-bottom: 0.95rem !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#sag-titel-text {
|
|||
|
|
font-size: clamp(1.45rem, 5vw, 1.9rem);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.case-header-meta-line {
|
|||
|
|
font-size: 0.9rem !important;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-top-row {
|
|||
|
|
margin-top: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-actions-cluster {
|
|||
|
|
width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: flex-start;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-action-btn {
|
|||
|
|
height: 32px;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-next-todo-chip {
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
order: 3;
|
|||
|
|
flex-basis: 100%;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 0.34rem 0.52rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-next-todo-chip .todo-label {
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
padding: 0.14rem 0.38rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-next-todo-chip #topbarNextTodoValue {
|
|||
|
|
font-size: 0.88rem !important;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
{% endblock %}
|
|||
|
|
|
|||
|
|
{% block content %}
|
|||
|
|
<div class="case-detail-page-shell" style="background-color: #f1f5f9; min-height: 100vh;">
|
|||
|
|
|
|||
|
|
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
|
|||
|
|
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -1rem;">
|
|||
|
|
<div class="container-fluid px-3 px-xl-4 position-relative">
|
|||
|
|
<div class="d-flex justify-content-between align-items-start mb-2 flex-wrap gap-2 header-top-row">
|
|||
|
|
<div class="d-flex align-items-center gap-3">
|
|||
|
|
<span class="badge bg-white text-primary fs-6 rounded-pill px-3 shadow-sm">#{{ case.id }}</span>
|
|||
|
|
<span class="badge {{ 'bg-success' if (case.status or '')|lower in ['lukket', 'løst', 'closed', 'resolved'] else 'bg-warning text-dark' }} fs-6 rounded-pill px-3 shadow-sm" id="caseStatusBadge">
|
|||
|
|
<i class="bi bi-circle-half me-1 small"></i>{{ case.status|capitalize if case.status else 'Åben' }}
|
|||
|
|
</span>
|
|||
|
|
{% set tkey = (case.template_key or case.type or 'ticket')|lower %}
|
|||
|
|
{% set type_icons = {'ticket': 'bi-ticket-perforated', 'pipeline': 'bi-graph-up-arrow', 'opgave': 'bi-puzzle', 'ordre': 'bi-receipt', 'projekt': 'bi-folder2-open', 'service': 'bi-tools'} %}
|
|||
|
|
{% set type_labels = {'ticket': 'Ticket', 'pipeline': 'Pipeline', 'opgave': 'Opgave', 'ordre': 'Ordre', 'projekt': 'Projekt', 'service': 'Service'} %}
|
|||
|
|
{% set type_colors = {'ticket': '#6366f1', 'pipeline': '#0ea5e9', 'opgave': '#f59e0b', 'ordre': '#10b981', 'projekt': '#8b5cf6', 'service': '#ef4444'} %}
|
|||
|
|
{% set tcolor = type_colors.get(tkey, '#0f4c75') %}
|
|||
|
|
{% set ticon = type_icons.get(tkey, 'bi-card-text') %}
|
|||
|
|
{% set tlabel = type_labels.get(tkey, tkey|capitalize) %}
|
|||
|
|
<span class="badge bg-white text-dark fs-6 rounded-pill px-3 shadow-sm border" id="caseTypeDropdownBtn">
|
|||
|
|
<i class="bi {{ ticon }} me-1" style="color:{{ tcolor }};" id="caseTypeIcon"></i><span id="caseTypeLabel">{{ tlabel }}</span>
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="header-actions-cluster position-relative z-3">
|
|||
|
|
<div class="header-next-todo-chip" title="Næste todo">
|
|||
|
|
<span class="todo-label"><i class="bi bi-list-check me-1"></i>Næste todo</span>
|
|||
|
|
<span id="topbarNextTodoValue">Henter næste todo...</span>
|
|||
|
|
<span id="topbarNextTodoMeta" class="d-none">-</span>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" class="btn header-action-btn" onclick="openManualHelp('Sag')" title="Hjælp til sagsbehandling"><i class="bi bi-question-circle"></i>Hjælp til: Sag</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-1">
|
|||
|
|
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
|
|||
|
|
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="letter-spacing: -0.5px;">{{ case.titel }}</h2>
|
|||
|
|
<button class="btn btn-sm btn-link text-white-50 p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
|
|||
|
|
</div>
|
|||
|
|
<div id="sag-titel-editor" class="d-none">
|
|||
|
|
<input id="sag-titel-input" type="text" class="form-control form-control-lg fw-bold" value="{{ case.titel }}" style="font-size:1.5rem;color:var(--accent);" />
|
|||
|
|
<div class="d-flex gap-2 mt-2">
|
|||
|
|
<button class="btn btn-sm btn-primary" onclick="saveTitelEdit()">Gem</button>
|
|||
|
|
<button class="btn btn-sm btn-secondary" onclick="cancelTitelEdit()">Annuller</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<p class="text-white-50 mb-0 case-header-meta-line">
|
|||
|
|
<i class="bi bi-building me-1"></i> <a href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" class="text-white text-decoration-none" title="Skift kunde">{{ customer.name if customer else 'Ingen kunde valgt' }}</a>
|
|||
|
|
<span class="meta-divider">|</span>
|
|||
|
|
<i class="bi bi-person me-1"></i> <a href="#" onclick="event.preventDefault(); showKontaktModal()" class="text-white text-decoration-none" title="Se/Rediger Kontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}</a>
|
|||
|
|
<span class="meta-divider">|</span>
|
|||
|
|
<i class="bi bi-diagram-3 me-1"></i> <a href="#" onclick="event.preventDefault(); showAfdelingModal()" class="text-white text-decoration-none" title="Ændre afdeling">{{ customer.department if customer and customer.department else 'Ingen afdeling' }}</a>
|
|||
|
|
<span class="meta-divider">|</span>
|
|||
|
|
<i class="bi bi-plus-circle me-1"></i> Oprettet: {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- WIDGET DASHBOARD -->
|
|||
|
|
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -1.25rem;">
|
|||
|
|
<div class="row g-2 mb-3">
|
|||
|
|
|
|||
|
|
<!-- Widget 1: Klassifikation -->
|
|||
|
|
<div class="col-md-6 col-xl-3">
|
|||
|
|
<div class="card h-100 shadow-sm border-0 rounded-3">
|
|||
|
|
<div class="card-body p-2">
|
|||
|
|
<div class="text-uppercase fw-bold text-muted mb-2" style="font-size:0.7rem; letter-spacing:0.45px;"><i class="bi bi-tags-fill text-primary me-1"></i> Klassifikation</div>
|
|||
|
|
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
|||
|
|
<select id="topbarStatusSelect" class="form-select form-select-sm border-start border-warning border-3 bg-light" onchange="saveCaseStatusFromTopbar()" style="width: 62%;">
|
|||
|
|
{% 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="d-flex justify-content-between align-items-center mb-1">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label>
|
|||
|
|
<select id="topbarTypeSelect" class="form-select form-select-sm border-start border-success border-3 bg-light" onchange="saveCaseTypeFromTopbar()" style="width: 62%;">
|
|||
|
|
{% 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="d-flex justify-content-between align-items-center">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Prioritet</label>
|
|||
|
|
<select id="topbarPrioritySelect" class="form-select form-select-sm border-start border-danger border-3 bg-light" onchange="saveCasePriorityFromTopbar()" style="width: 62%;">
|
|||
|
|
{% 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 %}>Høj</option>
|
|||
|
|
<option value="urgent" {% if topbar_priority == 'urgent' %}selected{% endif %}>Akut</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Widget 2: Tildeling -->
|
|||
|
|
<div class="col-md-6 col-xl-3">
|
|||
|
|
<div class="card h-100 shadow-sm border-0 rounded-3">
|
|||
|
|
<div class="card-body p-2 d-flex flex-column">
|
|||
|
|
<div class="text-uppercase fw-bold text-muted mb-2" style="font-size:0.7rem; letter-spacing:0.45px;"><i class="bi bi-people-fill text-success me-1"></i> Tildeling</div>
|
|||
|
|
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Ansvarlig</label>
|
|||
|
|
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
|
|||
|
|
<option value="">Ingen bruger</option>
|
|||
|
|
{% for user in assignment_users or [] %}
|
|||
|
|
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Gruppe</label>
|
|||
|
|
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
|
|||
|
|
<option value="">Ingen gruppe</option>
|
|||
|
|
{% for group in assignment_groups or [] %}
|
|||
|
|
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="d-none">
|
|||
|
|
<select id="assignmentUserSelect" onchange="saveAssignment()">
|
|||
|
|
<option value="">Ingen bruger</option>
|
|||
|
|
{% for user in assignment_users or [] %}
|
|||
|
|
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
<select id="assignmentGroupSelect" onchange="saveAssignment()">
|
|||
|
|
<option value="">Ingen gruppe</option>
|
|||
|
|
{% for group in assignment_groups or [] %}
|
|||
|
|
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Widget 3: Tidslinje -->
|
|||
|
|
<div class="col-md-6 col-xl-3">
|
|||
|
|
<div class="card h-100 shadow-sm border-0 rounded-3">
|
|||
|
|
<div class="card-body p-2">
|
|||
|
|
<div class="text-uppercase fw-bold text-muted mb-2" style="font-size:0.7rem; letter-spacing:0.45px;"><i class="bi bi-calendar-event-fill text-info me-1"></i> Tidslinje</div>
|
|||
|
|
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Start</label>
|
|||
|
|
<div class="input-group input-group-sm" style="width: 62%;">
|
|||
|
|
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}" onchange="saveCaseStartDateFromTopbar()">
|
|||
|
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
|||
|
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
|||
|
|
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
|||
|
|
<li><hr class="dropdown-divider"></li>
|
|||
|
|
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(0);"><i class="bi bi-calendar-event me-2 text-muted"></i>I dag</a></li>
|
|||
|
|
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(1);"><i class="bi bi-calendar-plus me-2 text-muted"></i>I morgen</a></li>
|
|||
|
|
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(7);"><i class="bi bi-calendar-week me-2 text-muted"></i>Næste uge</a></li>
|
|||
|
|
<li><hr class="dropdown-divider"></li>
|
|||
|
|
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openStartDateModal();"><i class="bi bi-gear me-2 text-muted"></i>Flere valg...</a></li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;" title="Start senest (Trigger)">Senest/Udsat</label>
|
|||
|
|
<div class="input-group input-group-sm" style="width: 62%;">
|
|||
|
|
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}" onchange="updateDeferredUntil(this.value || null)">
|
|||
|
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
|||
|
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
|||
|
|
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
|||
|
|
<li><hr class="dropdown-divider"></li>
|
|||
|
|
<li><h6 class="dropdown-header">Venter på sag...</h6></li>
|
|||
|
|
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModalWithPresetStatus('lukket');"><i class="bi bi-check2-circle me-2 text-success"></i>Trigger: Løst/Lukket</a></li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="d-flex justify-content-between align-items-center">
|
|||
|
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Deadline</label>
|
|||
|
|
<div class="input-group input-group-sm" style="width: 62%;">
|
|||
|
|
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}" onchange="updateDeadline(this.value || null)">
|
|||
|
|
<button class="btn btn-outline-danger bg-light" type="button" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x"></i></button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mt-1 text-truncate" id="topbarDeferredStatusIndicator">
|
|||
|
|
{% if case.deferred_until_case_id and case.deferred_until_status %}
|
|||
|
|
<span class="badge bg-warning text-dark w-100 text-start text-truncate fw-normal border border-warning" title="Trigger aktiv"><i class="bi bi-link-45deg"></i> Trigger #{{ case.deferred_until_case_id }} → {{ case.deferred_until_status|replace('__any_change__', 'Skift') }}</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Widget 4: Alerts & Genveje -->
|
|||
|
|
<div class="col-md-6 col-xl-3">
|
|||
|
|
<div class="card h-100 shadow-sm border-0 rounded-3" style="background:#f8fafc;">
|
|||
|
|
<div class="card-body p-2">
|
|||
|
|
<div class="text-uppercase fw-bold text-muted mb-2" style="font-size:0.7rem; letter-spacing:0.45px;"><i class="bi bi-info-circle-fill text-secondary me-1"></i> System Info</div>
|
|||
|
|
|
|||
|
|
<div id="caseCustomerTopAlerts" class="mb-2 d-none"></div>
|
|||
|
|
|
|||
|
|
<div class="d-grid gap-2">
|
|||
|
|
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseAnyDeskModal()"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
|
|||
|
|
<button type="button" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseWorkOrderPrint()"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ═══════════════ END CASE HEADER V3 ═══════════════ -->
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// Defensive bootstrap: keep core tab/module globals available even if a later
|
|||
|
|
// inline script fails to parse due template edge-cases.
|
|||
|
|
var caseTypeModuleDefaults = window.caseTypeModuleDefaults || {};
|
|||
|
|
window.caseTypeModuleDefaults = caseTypeModuleDefaults;
|
|||
|
|
window.caseTypeKey = window.caseTypeKey || {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
|
|||
|
|
|
|||
|
|
window.forceCaseTabActivation = window.forceCaseTabActivation || function(tabId) {
|
|||
|
|
if (!tabId) return;
|
|||
|
|
const tabContent = document.getElementById('caseTabsContent');
|
|||
|
|
const targetPane = document.getElementById(tabId);
|
|||
|
|
if (!tabContent || !targetPane) return;
|
|||
|
|
|
|||
|
|
tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => {
|
|||
|
|
pane.classList.remove('show', 'active');
|
|||
|
|
pane.style.display = 'none';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
targetPane.classList.add('show', 'active');
|
|||
|
|
targetPane.style.display = 'block';
|
|||
|
|
|
|||
|
|
const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]');
|
|||
|
|
tabButtons.forEach((btn) => {
|
|||
|
|
btn.classList.toggle('active', btn.getAttribute('data-bs-target') === `#${tabId}`);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<!-- Tabs Navigation -->
|
|||
|
|
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
|
|||
|
|
<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 %}
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
<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>
|
|||
|
|
<li class="nav-item" role="presentation">
|
|||
|
|
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab" data-module-tab="history" onclick="forceCaseTabActivation('history', this)">
|
|||
|
|
<i class="bi bi-clock-history me-2"></i>Historik
|
|||
|
|
<span class="case-tab-count-badge" id="historyTabCountBadge"></span>
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
</ul>
|
|||
|
|
|
|||
|
|
<div class="tab-content" id="caseTabsContent">
|
|||
|
|
<!-- Tab: Sagsdetaljer (Existing Content) -->
|
|||
|
|
<div class="tab-pane fade show active" id="details" role="tabpanel" tabindex="0" style="display:block;">
|
|||
|
|
<div class="row g-4">
|
|||
|
|
<div class="col-xl-8 col-lg-8" id="case-left-column">
|
|||
|
|
<div class="row g-4">
|
|||
|
|
<!-- TREDELT-1: Relations, History, etc. -->
|
|||
|
|
<div class="col-12" id="inner-left-col">
|
|||
|
|
</div>
|
|||
|
|
<!-- TREDELT-2: Hero, Info -->
|
|||
|
|
<div class="col-12" id="inner-center-col">
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
<!-- ROW 1: Main Info -->
|
|||
|
|
<div class="row mb-3">
|
|||
|
|
<!-- MAIN HERO CARD: Titel & Beskrivelse -->
|
|||
|
|
<div class="col-12 mb-3 mt-1">
|
|||
|
|
<div class="card shadow-sm border-0 border-start border-4 border-primary" style="background-color: var(--bg-card); border-radius: 8px;">
|
|||
|
|
<div class="card-body p-3 position-relative">
|
|||
|
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
|||
|
|
<div class="w-100 pe-3">
|
|||
|
|
<div class="d-flex align-items-center gap-2">
|
|||
|
|
<h2 class="mb-2 fw-bolder" style="color: var(--accent); font-size: 1.8rem; letter-spacing: -0.5px;">Sagsbeskrivelse</h2>
|
|||
|
|
<button class="btn btn-sm btn-link text-info p-0 mb-1 ms-1" onclick="openManualHelp('Sag')" title="Hjælp til sagsbehandling"><i class="bi bi-question-circle fs-5"></i></button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mt-4 pt-3 border-top border-light" id="beskrivelse-section">
|
|||
|
|
<div class="case-left-panel">
|
|||
|
|
<div class="case-left-section-title"><i class="bi bi-card-text"></i>Opgavebeskrivelse</div>
|
|||
|
|
<!-- View mode -->
|
|||
|
|
<div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
|
|||
|
|
<div class="narrative-lead">{{ case.titel }}</div>
|
|||
|
|
<div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ case.beskrivelse or '' }}</div>
|
|||
|
|
{% if not case.beskrivelse %}
|
|||
|
|
<div id="beskrivelse-empty" class="text-center p-3">
|
|||
|
|
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
|
|||
|
|
<span class="text-muted small"><i class="bi bi-pencil me-1"></i>Dobbeltklik for at tilføje</span>
|
|||
|
|
</div>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Edit mode (hidden by default) -->
|
|||
|
|
<div id="beskrivelse-editor" class="d-none mt-1">
|
|||
|
|
<textarea id="beskrivelse-textarea" class="form-control"
|
|||
|
|
rows="8" style="font-size: 1rem; line-height: 1.7; resize: vertical; min-height: 150px;"></textarea>
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
|||
|
|
<span class="text-muted small"><i class="bi bi-keyboard me-1"></i>Ctrl+Enter for at gemme · Esc for at annullere</span>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="cancelBeskrivelsEdit()">
|
|||
|
|
<i class="bi bi-x me-1"></i>Annuller
|
|||
|
|
</button>
|
|||
|
|
<button id="beskrivelse-rewrite-btn" type="button" class="btn btn-sm btn-outline-primary" onclick="rewriteCaseDescriptionWithApproval()">
|
|||
|
|
<i class="bi bi-magic me-1"></i>Renskriv med AI
|
|||
|
|
</button>
|
|||
|
|
<button id="beskrivelse-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()">
|
|||
|
|
<i class="bi bi-check2 me-1"></i>Gem
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="beskrivelse-comments-wrap">
|
|||
|
|
<div class="comment-toolbar">
|
|||
|
|
<h6 class="comment-title-inline"><i class="bi bi-chat-left-text me-1"></i>Kommentarer</h6>
|
|||
|
|
<div class="d-flex align-items-center gap-2 flex-shrink-0">
|
|||
|
|
<span id="commentsVisibleCountBadge" class="badge text-bg-light">0/0 vises</span>
|
|||
|
|
<span id="commentsTotalCountBadge" class="badge bg-secondary">{{ comments|length if comments else 0 }}</span>
|
|||
|
|
</div>
|
|||
|
|
<input id="commentFilterText" type="text" class="form-control form-control-sm comment-toolbar-search" placeholder="Søg...">
|
|||
|
|
<span class="comment-filter-group-label">Kilde</span>
|
|||
|
|
<label class="comment-category-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="commentSourceFilterEmail" checked>
|
|||
|
|
<span>Email (<span id="commentSourceCountEmail" class="count">0</span>)</span>
|
|||
|
|
</label>
|
|||
|
|
<label class="comment-category-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="commentSourceFilterSms" checked>
|
|||
|
|
<span>SMS (<span id="commentSourceCountSms" class="count">0</span>)</span>
|
|||
|
|
</label>
|
|||
|
|
<label class="comment-category-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="commentSourceFilterCall" checked>
|
|||
|
|
<span>Kald (<span id="commentSourceCountCall" class="count">0</span>)</span>
|
|||
|
|
</label>
|
|||
|
|
<label class="comment-category-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="commentSourceFilterModule" checked>
|
|||
|
|
<span>Andet/modul (<span id="commentSourceCountModule" class="count">0</span>)</span>
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="comment-thread" id="comments-container">
|
|||
|
|
{% if comments %}
|
|||
|
|
{% for comment in comments %}
|
|||
|
|
{% if comment.er_system_besked or comment.forfatter == 'System' %}
|
|||
|
|
<div class="comment-item comment-system" data-category="system" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
|
|||
|
|
{% elif comment.er_intern %}
|
|||
|
|
<div class="comment-item comment-internal" data-category="internal" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
|
|||
|
|
{% else %}
|
|||
|
|
<div class="comment-item comment-external" data-category="external" data-created-at="{{ comment.created_at.timestamp() if comment.created_at else 0 }}">
|
|||
|
|
{% endif %}
|
|||
|
|
<div class="comment-meta">
|
|||
|
|
<span class="comment-avatar">{{ (comment.forfatter or 'Bruger')[:2]|upper }}</span>
|
|||
|
|
<b>{{ comment.forfatter }}</b>
|
|||
|
|
{% if comment.er_system_besked or comment.forfatter == 'System' %}
|
|||
|
|
<span class="comment-category-badge category-system">System</span>
|
|||
|
|
{% elif comment.er_intern %}
|
|||
|
|
<span class="comment-category-badge category-internal">Intern</span>
|
|||
|
|
{% else %}
|
|||
|
|
<span class="comment-category-badge category-external">Ekstern</span>
|
|||
|
|
{% endif %}
|
|||
|
|
<span class="comment-time">{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="comment-body" data-comment-raw="{{ comment.indhold|e }}">{{ comment.indhold|replace('\n', '<br>')|safe }}</div>
|
|||
|
|
</div>
|
|||
|
|
{% endfor %}
|
|||
|
|
{% else %}
|
|||
|
|
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
<div id="commentFilterEmptyState" class="comment-empty-filter d-none">Ingen kommentarer matcher de valgte filtre.</div>
|
|||
|
|
|
|||
|
|
<div id="comment-quick-reply-host"></div>
|
|||
|
|
|
|||
|
|
<div class="mt-3">
|
|||
|
|
<form id="comment-form" onsubmit="submitComment(event)">
|
|||
|
|
<div class="comment-composer">
|
|||
|
|
<textarea class="form-control" name="indhold" required placeholder="Skriv en kommentar..." rows="2" style="resize: none;"></textarea>
|
|||
|
|
<button type="submit" class="btn btn-primary d-flex align-items-center justify-content-center comment-send">
|
|||
|
|
<i class="bi bi-send me-1"></i>Send
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ROW 1B: Pipeline -->
|
|||
|
|
|
|||
|
|
|
|||
|
|
<!-- ROW 2: Relations -->
|
|||
|
|
|
|||
|
|
|
|||
|
|
<!-- ROW: Call History -->
|
|||
|
|
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
let caseCurrentUserId = null;
|
|||
|
|
|
|||
|
|
async function ensureCaseCurrentUserId() {
|
|||
|
|
if (caseCurrentUserId !== null) return caseCurrentUserId;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
|||
|
|
if (!res.ok) return null;
|
|||
|
|
const me = await res.json();
|
|||
|
|
caseCurrentUserId = Number(me?.id) || null;
|
|||
|
|
return caseCurrentUserId;
|
|||
|
|
} catch (e) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function logCaseSystemComment(message) {
|
|||
|
|
try {
|
|||
|
|
await fetch('/api/v1/sag/{{ case.id }}/kommentarer', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
credentials: 'include',
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
forfatter: 'System',
|
|||
|
|
indhold: message,
|
|||
|
|
er_system_besked: true
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('Kunne ikke logge systemkommentar', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!window.__caseSmsCommentListenerBound) {
|
|||
|
|
window.__caseSmsCommentListenerBound = true;
|
|||
|
|
window.addEventListener('bmc:sms-sent', async (event) => {
|
|||
|
|
try {
|
|||
|
|
const detail = event?.detail || {};
|
|||
|
|
const to = String(detail.to || '').trim();
|
|||
|
|
const text = String(detail.message || '').replace(/\s+/g, ' ').trim();
|
|||
|
|
if (!to || !text) return;
|
|||
|
|
|
|||
|
|
const preview = text.length > 220 ? `${text.slice(0, 217)}...` : text;
|
|||
|
|
await logCaseSystemComment(`💬 SMS sendt til ${to}: ${preview}`);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('Kunne ikke logge sendt SMS i kommentarer', err);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function ringOutFromCase(number, opts = {}) {
|
|||
|
|
const clean = String(number || '').trim();
|
|||
|
|
if (!clean || clean === '-') {
|
|||
|
|
if (!opts.silent) alert('Intet gyldigt nummer at ringe til');
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const userId = await ensureCaseCurrentUserId();
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/telefoni/click-to-call', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
credentials: 'include',
|
|||
|
|
body: JSON.stringify({ number: clean, user_id: userId })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const t = await res.text();
|
|||
|
|
if (!opts.silent) alert('Ring ud fejlede: ' + t);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (opts.logToCase) {
|
|||
|
|
const who = opts.contactName ? `${opts.contactName} (${clean})` : clean;
|
|||
|
|
await logCaseSystemComment(`📞 Opkald startet til ${who}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!opts.silent) alert('Ringer ud via Yealink...');
|
|||
|
|
return true;
|
|||
|
|
} catch (e) {
|
|||
|
|
if (!opts.silent) alert('Kunne ikke starte opkald');
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
|
|||
|
|
<!-- Relationer (center) -->
|
|||
|
|
<div class="row mb-3">
|
|||
|
|
<div class="col-12 mb-3">
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<div class="d-flex align-items-center gap-2">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-diagram-3-fill module-icon"></i>Relationer</h6>
|
|||
|
|
<i class="bi bi-info-circle text-muted"
|
|||
|
|
style="font-size:0.9rem; cursor:help;"
|
|||
|
|
data-bs-toggle="tooltip"
|
|||
|
|
data-bs-html="true"
|
|||
|
|
data-bs-placement="right"
|
|||
|
|
title="<strong>Hvad betyder relationstyper?</strong><br><br><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.<br><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.<br><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.<br><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden."></i>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
|||
|
|
<i class="bi bi-link-45deg"></i>
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="showCreateRelatedModal()">
|
|||
|
|
<i class="bi bi-plus-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
|
|||
|
|
{% macro render_relation_rows(nodes, level=0) %}
|
|||
|
|
{% for node in nodes %}
|
|||
|
|
<tr class="{{ 'table-primary' if node.is_current else '' }}">
|
|||
|
|
<td class="small text-muted">{{ level }}</td>
|
|||
|
|
<td>
|
|||
|
|
<span class="badge bg-light text-dark border">#{{ node.case.id }}</span>
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
{% if node.is_current %}
|
|||
|
|
<span class="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
|
|||
|
|
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
|
|||
|
|
{% else %}
|
|||
|
|
<a href="/sag/{{ node.case.id }}" class="text-decoration-none fw-semibold">{{ node.case.titel }}</a>
|
|||
|
|
{% endif %}
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
{% set row_status = (node.case.status or '')|lower %}
|
|||
|
|
<span class="badge {{ 'bg-success-subtle text-success border border-success-subtle' if row_status == 'åben' else 'bg-secondary-subtle text-secondary border border-secondary-subtle' }}">
|
|||
|
|
{{ node.case.status or '-' }}
|
|||
|
|
</span>
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
<span class="badge bg-light text-dark border">{{ node.case.template_key or node.case.type or 'ticket' }}</span>
|
|||
|
|
</td>
|
|||
|
|
<td>
|
|||
|
|
{% set rel_raw = (node.relation_type or '')|trim %}
|
|||
|
|
{% set rel_key = rel_raw|lower %}
|
|||
|
|
{% if node.is_current %}
|
|||
|
|
<span class="relation-type-pill is-root" title="Rodsag i visningen.">Rodsag</span>
|
|||
|
|
{% elif rel_key == 'afledt af' %}
|
|||
|
|
<span class="relation-type-pill is-derived" title="Denne sag kommer fra en anden sag. Funktion: styrer retning i relationstræet.">Kommer fra</span>
|
|||
|
|
<div class="relation-type-subtext">(Afledt af)</div>
|
|||
|
|
{% elif rel_key == 'årsag til' %}
|
|||
|
|
<span class="relation-type-pill is-cause" title="Denne sag skaber en følge-sag. Funktion: styrer retning i relationstræet.">Skaber følge-sag</span>
|
|||
|
|
<div class="relation-type-subtext">(Årsag til)</div>
|
|||
|
|
{% elif rel_key == 'blokkerer' %}
|
|||
|
|
<span class="relation-type-pill is-block" title="Denne sag blokerer den anden, indtil den er løst.">Blokerer</span>
|
|||
|
|
{% else %}
|
|||
|
|
<span class="relation-type-pill is-related" title="Sagerne er fagligt koblet uden direkte afhængighed.">Koblet til</span>
|
|||
|
|
<div class="relation-type-subtext">(Relateret til)</div>
|
|||
|
|
{% endif %}
|
|||
|
|
{% if node.is_repeated %}
|
|||
|
|
<i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i>
|
|||
|
|
{% endif %}
|
|||
|
|
</td>
|
|||
|
|
<td class="text-end">
|
|||
|
|
<div class="btn-group btn-group-sm" role="group">
|
|||
|
|
{% if node.relation_id %}
|
|||
|
|
<button onclick="deleteRelation({{ node.relation_id }})" class="btn btn-outline-danger btn-rel-action" title="Fjern relation">
|
|||
|
|
<i class="bi bi-x-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
{% endif %}
|
|||
|
|
<button class="btn btn-outline-secondary btn-rel-action" title="Tags" onclick="openRelTagPopover({{ node.case.id }})">
|
|||
|
|
<i class="bi bi-tag"></i>
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-outline-primary btn-rel-action" title="Quick action" onclick='openRelQaMenu({{ node.case.id }}, {{ (node.case.titel or "")|tojson }}, this)'>
|
|||
|
|
<i class="bi bi-plus-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
{% if node.children %}
|
|||
|
|
{{ render_relation_rows(node.children, level + 1) }}
|
|||
|
|
{% endif %}
|
|||
|
|
{% endfor %}
|
|||
|
|
{% endmacro %}
|
|||
|
|
|
|||
|
|
{% set has_relations = relation_tree and (relation_tree|length > 1 or (relation_tree|length == 1 and relation_tree[0].children)) %}
|
|||
|
|
{% if has_relations %}
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-hover table-sm align-middle mb-0">
|
|||
|
|
<thead class="table-light sticky-top">
|
|||
|
|
<tr>
|
|||
|
|
<th style="width: 56px;">Niv.</th>
|
|||
|
|
<th style="width: 88px;">Sag</th>
|
|||
|
|
<th>Titel</th>
|
|||
|
|
<th style="width: 120px;">Status</th>
|
|||
|
|
<th style="width: 130px;">Type</th>
|
|||
|
|
<th style="width: 180px;">Sammenhæng</th>
|
|||
|
|
<th class="text-end" style="width: 130px;">Handling</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{{ render_relation_rows(relation_tree) }}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
{% else %}
|
|||
|
|
<p class="text-muted text-center pt-3"><i class="bi bi-diagram-3 me-1"></i>Ingen relaterede sager</p>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ROW 3: Files + Linked Emails -->
|
|||
|
|
<div class="row mb-3">
|
|||
|
|
<!-- Files -->
|
|||
|
|
<div class="col-12 mb-3">
|
|||
|
|
<div class="card h-100 right-module-card module-priority-low" data-module="files" data-has-content="unknown">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-folder2-open module-icon"></i>Filer & Dokumenter</h6>
|
|||
|
|
<input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileUpload(this.files)">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('fileInput').click()">
|
|||
|
|
<i class="bi bi-cloud-upload"></i> Upload
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<!-- Drag & Drop Zone -->
|
|||
|
|
<div class="card-body p-0 d-flex flex-column" id="fileDropZone">
|
|||
|
|
<div class="p-4 text-center border-bottom bg-light" id="fileDropMessage" style="cursor: pointer;" onclick="document.getElementById('fileInput').click()">
|
|||
|
|
<i class="bi bi-cloud-arrow-up display-6 text-muted"></i>
|
|||
|
|
<p class="small text-muted mb-0 mt-2">Træk filer hertil for at uploade</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="files-list" style="max-height: 300px;">
|
|||
|
|
<div class="p-3 text-center text-muted">Ingen filer fundet...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- File Preview Modal -->
|
|||
|
|
<div class="modal fade" id="filePreviewModal" tabindex="-1" data-bs-backdrop="static">
|
|||
|
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">
|
|||
|
|
<i class="bi bi-file-earmark-text me-2"></i>
|
|||
|
|
<span id="previewFileName">Fil preview</span>
|
|||
|
|
</h5>
|
|||
|
|
<div class="ms-auto d-flex align-items-center gap-2">
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body p-0" style="min-height: 60vh; max-height: 80vh; overflow: hidden;">
|
|||
|
|
<div id="previewContent" class="w-100 h-100 d-flex align-items-center justify-content-center">
|
|||
|
|
<div class="spinner-border text-primary" role="status">
|
|||
|
|
<span class="visually-hidden">Indlæser...</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Search Modals -->
|
|||
|
|
<div class="modal fade" id="contactSearchModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Søg kontakt</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<input type="text" id="contactSearch" placeholder="Søg efter kontakt..." class="form-control mb-3">
|
|||
|
|
<div id="contactSearchResults" style="max-height: 300px; overflow-y: auto;"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="modal fade" id="customerSearchModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title" id="customerSearchModalTitle">Søg kunde</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<input type="text" id="customerSearch" placeholder="Søg efter kunde..." class="form-control mb-3">
|
|||
|
|
<div id="customerSearchResults" style="max-height: 300px; overflow-y: auto;"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="modal fade" id="relationModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog modal-lg">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">🔗 Tilføj relation til sag</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label fw-bold">1. Søg og vælg sag</label>
|
|||
|
|
<input type="text"
|
|||
|
|
id="relationCaseSearch"
|
|||
|
|
placeholder="Søg efter sag ID, titel, kunde eller beskrivelse..."
|
|||
|
|
class="form-control form-control-lg"
|
|||
|
|
autocomplete="off">
|
|||
|
|
<div id="relationSearchResults"
|
|||
|
|
style="max-height: 400px; overflow-y: auto; margin-top: 0.5rem;"
|
|||
|
|
class="border rounded"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="selectedCasePreview" style="display: none;" class="alert alert-info mb-3">
|
|||
|
|
<div class="d-flex justify-content-between align-items-start">
|
|||
|
|
<div>
|
|||
|
|
<strong>Valgt sag:</strong>
|
|||
|
|
<div id="selectedCaseTitle" class="mt-1"></div>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSelectedRelationCase()">
|
|||
|
|
Ryd valg
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label fw-bold">2. Vælg relationstype</label>
|
|||
|
|
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
|
|||
|
|
<option value="">Vælg hvordan sagerne er relateret...</option>
|
|||
|
|
<option value="Relateret til">🔗 Koblet til (Relateret) - Faglig kobling uden direkte afhængighed</option>
|
|||
|
|
<option value="Afledt af">↪ Kommer fra (Afledt af) - Denne sag er opstået pga. den anden</option>
|
|||
|
|
<option value="Årsag til">➡ Skaber følge-sag (Årsag til) - Denne sag skaber den anden</option>
|
|||
|
|
<option value="Blokkerer">⛔ Blokerer - Den anden kan ikke videre før denne er løst</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="relationTypeHint" class="alert alert-info small mb-3" style="display:none;"></div>
|
|||
|
|
|
|||
|
|
<div class="alert alert-light border small mb-3">
|
|||
|
|
<div class="fw-semibold mb-1">Betydning i praksis</div>
|
|||
|
|
<div><strong>Koblet til (Relateret)</strong>: Faglig sammenhæng, men ingen direkte afhængighed.</div>
|
|||
|
|
<div><strong>Kommer fra (Afledt af)</strong>: Sagen er opstået pga. en anden sag.</div>
|
|||
|
|
<div><strong>Skaber følge-sag (Årsag til)</strong>: Sagen skaber behovet for en anden sag.</div>
|
|||
|
|
<div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
|
|||
|
|
<i class="bi bi-info-circle me-2"></i>
|
|||
|
|
<div>
|
|||
|
|
<strong>Tip:</strong> Brug pile (↑↓) til at navigere i søgeresultater, Enter til at vælge.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button"
|
|||
|
|
class="btn btn-primary btn-lg"
|
|||
|
|
onclick="addRelation()"
|
|||
|
|
id="addRelationBtn"
|
|||
|
|
disabled>
|
|||
|
|
<i class="bi bi-plus-circle me-1"></i> Tilføj relation
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Contact Info Modal -->
|
|||
|
|
<div class="modal fade" id="contactInfoModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title" id="contactInfoName">Kontakt</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<div class="text-muted small">Titel</div>
|
|||
|
|
<div id="contactInfoTitle">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<div class="text-muted small">Kunde</div>
|
|||
|
|
<div id="contactInfoCompany">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<div class="text-muted small">E-mail</div>
|
|||
|
|
<div id="contactInfoEmail">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<div class="text-muted small">Telefon</div>
|
|||
|
|
<div id="contactInfoPhone">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<div class="text-muted small">Mobil</div>
|
|||
|
|
<div id="contactInfoMobile">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<div class="text-muted small">Rolle</div>
|
|||
|
|
<div id="contactInfoRole">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div id="contactInfoPrimary" class="badge bg-primary d-none">Hovedkontakt</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<div class="me-auto d-flex gap-2 flex-wrap">
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-success" onclick="callCurrentContactFromInfo()"><i class="bi bi-telephone me-1"></i>Ring</button>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="smsCurrentContactFromInfo()"><i class="bi bi-chat-left-text me-1"></i>SMS</button>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="emailCurrentContactFromInfo()"><i class="bi bi-envelope me-1"></i>Email</button>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="openContactRoleFromInfo()">Rediger rolle</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Contact Role Modal -->
|
|||
|
|
<div class="modal fade" id="contactRoleModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Rediger kontaktrolle</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<input type="hidden" id="contactRoleContactId" />
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label">Kontakt</label>
|
|||
|
|
<div id="contactRoleName" class="fw-semibold">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Rolle</label>
|
|||
|
|
<input type="text" class="form-control" id="contactRoleInput" placeholder="fx ansvarlig, beslutningstager">
|
|||
|
|
</div>
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="contactRolePrimary">
|
|||
|
|
<label class="form-check-label" for="contactRolePrimary">Sæt som hovedkontakt</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveContactRole()">Gem</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Deadline Modal -->
|
|||
|
|
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Deadline</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<label class="form-label">Dato</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
class="form-control form-control-sm"
|
|||
|
|
id="deadlineInput"
|
|||
|
|
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
|
|||
|
|
/>
|
|||
|
|
<div class="defer-controls mt-2">
|
|||
|
|
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(1)">+1 dag</button>
|
|||
|
|
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(7)">+1 uge</button>
|
|||
|
|
<button class="btn btn-outline-primary" onclick="shiftDeadlineMonths(1)">+1 mnd</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-outline-danger" onclick="clearDeadlineAll()">Ryd</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveDeadlineAll()">Gem</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Deferred Modal -->
|
|||
|
|
<div class="modal fade" id="deferredModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Udsat start</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<label class="form-label">Dato</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
class="form-control form-control-sm"
|
|||
|
|
id="deferredUntilInput"
|
|||
|
|
value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}"
|
|||
|
|
/>
|
|||
|
|
<div class="defer-controls mt-2">
|
|||
|
|
<button class="btn btn-outline-primary" onclick="shiftDeferredDays(1)">+1 dag</button>
|
|||
|
|
<button class="btn btn-outline-primary" onclick="shiftDeferredDays(7)">+1 uge</button>
|
|||
|
|
<button class="btn btn-outline-primary" onclick="shiftDeferredMonths(1)">+1 mnd</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<label class="form-label mt-3">Start når anden sag har status</label>
|
|||
|
|
<select class="form-select form-select-sm" id="deferredCaseSelect">
|
|||
|
|
<option value="">Vælg relateret sag</option>
|
|||
|
|
{% for rc in related_case_options %}
|
|||
|
|
<option value="{{ rc.id }}" {% if case.deferred_until_case_id == rc.id %}selected{% endif %}>
|
|||
|
|
#{{ rc.id }} – {{ rc.titel }}
|
|||
|
|
</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
{% set deferred_status_values = (case.deferred_until_status or '').lower().replace(', ', ',').split(',') %}
|
|||
|
|
<select class="form-select form-select-sm mt-2" id="deferredStatusSelect" multiple size="6">
|
|||
|
|
<option value="__any_change__" {% if '__any_change__' in deferred_status_values %}selected{% endif %}>
|
|||
|
|
Ved statusskifte fra nuværende
|
|||
|
|
</option>
|
|||
|
|
{% for st in status_options %}
|
|||
|
|
<option value="{{ st }}" {% if st|lower in deferred_status_values %}selected{% endif %}>
|
|||
|
|
{{ st }}
|
|||
|
|
</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
<div class="form-text mt-1">Du kan vælge flere statuser (Cmd/Ctrl + klik).</div>
|
|||
|
|
<div class="defer-controls mt-2">
|
|||
|
|
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredCloseTrigger()">Når valgt sag lukkes</button>
|
|||
|
|
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredResolvedTrigger()">Når valgt sag er løst</button>
|
|||
|
|
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredAnyStatusChangeTrigger()">Ved statusskifte</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-outline-danger" onclick="clearDeferredAll()">Ryd</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveDeferredAll()">Gem</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Start Date Modal -->
|
|||
|
|
<div class="modal fade" id="startDateModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Arbejdsstart</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<label class="form-label">Dato</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
class="form-control form-control-sm"
|
|||
|
|
id="startDateModalInput"
|
|||
|
|
value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}"
|
|||
|
|
/>
|
|||
|
|
<div class="defer-controls mt-2">
|
|||
|
|
<button class="btn btn-outline-primary" type="button" onclick="setStartDateModalToday()">I dag</button>
|
|||
|
|
<button class="btn btn-outline-primary" type="button" onclick="shiftStartDateDays(1)">+1 dag</button>
|
|||
|
|
<button class="btn btn-outline-primary" type="button" onclick="shiftStartDateDays(7)">+1 uge</button>
|
|||
|
|
<button class="btn btn-outline-primary" type="button" onclick="shiftStartDateMonths(1)">+1 mnd</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-outline-danger" onclick="clearStartDateFromModal()">Ryd</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveStartDateFromModal()">Gem</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Module Control Modal -->
|
|||
|
|
<div class="modal fade" id="moduleControlModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Vis/skjul moduler</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body" id="moduleControlList">
|
|||
|
|
<div class="text-muted small">Indlæser...</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
{% if hovedkontakt %}
|
|||
|
|
<div class="me-auto d-flex gap-2 flex-wrap">
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-success" onclick="callMainContactFromModal()"><i class="bi bi-telephone me-1"></i>Ring</button>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="smsMainContactFromModal()"><i class="bi bi-chat-left-text me-1"></i>SMS</button>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="emailMainContactFromModal()"><i class="bi bi-envelope me-1"></i>Email</button>
|
|||
|
|
</div>
|
|||
|
|
{% endif %}
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- AnyDesk quick-connect modal -->
|
|||
|
|
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title"><i class="bi bi-display me-2"></i>AnyDesk quick connect</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">AnyDesk ID</label>
|
|||
|
|
<div class="input-group">
|
|||
|
|
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
|
|||
|
|
<a href="#" class="btn btn-outline-primary" id="caseAnydeskOpenLinkBtn" target="_self" style="display:none;">
|
|||
|
|
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn AnyDesk
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-text">ID gemmes automatisk på sagen når du klikker forbind.</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Gemte IDs på sagen</label>
|
|||
|
|
<div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
|
|||
|
|
<span class="text-muted small">Indlæser...</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Relatér til hardware (valgfri)</label>
|
|||
|
|
<select class="form-select" id="caseAnydeskHardwareSelect">
|
|||
|
|
<option value="">Ingen hardware valgt</option>
|
|||
|
|
</select>
|
|||
|
|
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Kontakt (valgfri)</label>
|
|||
|
|
<select class="form-select" id="caseAnydeskContactSelect">
|
|||
|
|
<option value="">Ingen kontakt</option>
|
|||
|
|
{% for contact in contacts %}
|
|||
|
|
<option value="{{ contact.contact_id }}">{{ contact.contact_name }}{% if contact.customer_name %} - {{ contact.customer_name }}{% endif %}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-1">
|
|||
|
|
<label class="form-label">Notat (valgfri)</label>
|
|||
|
|
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
|
|||
|
|
</div>
|
|||
|
|
<div class="small text-muted mt-2">
|
|||
|
|
Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const caseId = {{ case.id }};
|
|||
|
|
const caseCustomerId = {{ case.customer_id if case.customer_id else 'null' }};
|
|||
|
|
const wikiCustomerId = {{ customer.id if customer else 'null' }};
|
|||
|
|
const wikiDefaultTag = "guide";
|
|||
|
|
let contactSearchTimeout;
|
|||
|
|
let customerSearchTimeout;
|
|||
|
|
let relationSearchTimeout;
|
|||
|
|
let wikiSearchTimeout;
|
|||
|
|
let selectedRelationCaseId = null;
|
|||
|
|
let customerSearchMode = 'link';
|
|||
|
|
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
|
|||
|
|
|
|||
|
|
async function markCaseAsRecentlyOpened() {
|
|||
|
|
try {
|
|||
|
|
await fetch(`/api/v1/sag/recent/open/${caseId}`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
credentials: 'include',
|
|||
|
|
keepalive: true,
|
|||
|
|
headers: { 'Accept': 'application/json' }
|
|||
|
|
});
|
|||
|
|
} catch (_err) {
|
|||
|
|
// Best effort only; page should not break if this call fails.
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeCaseTopAlertHtml(value) {
|
|||
|
|
return String(value ?? '')
|
|||
|
|
.replace(/&/g, '&')
|
|||
|
|
.replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
.replace(/'/g, ''');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadCaseCustomerTopAlerts() {
|
|||
|
|
const container = document.getElementById('caseCustomerTopAlerts');
|
|||
|
|
if (!container || !caseCustomerId) {
|
|||
|
|
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=${caseCustomerId}`, {
|
|||
|
|
credentials: 'include'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`HTTP ${response.status}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = await response.json();
|
|||
|
|
const alerts = (payload?.alerts || []).filter((alert) => {
|
|||
|
|
const severity = String(alert?.severity || '').toLowerCase();
|
|||
|
|
return severity === 'critical' || severity === 'warning';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!alerts.length) {
|
|||
|
|
container.classList.add('d-none');
|
|||
|
|
container.innerHTML = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = alerts.map((alert) => {
|
|||
|
|
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
|
|||
|
|
const level = isCritical ? 'KRITISK' : 'ADVARSEL';
|
|||
|
|
const klass = isCritical ? 'alert-danger' : 'alert-warning';
|
|||
|
|
const title = escapeCaseTopAlertHtml(alert.title || 'Vigtig kundeinformation');
|
|||
|
|
const message = escapeCaseTopAlertHtml(alert.message || '');
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="alert ${klass} mb-2" role="alert" style="border-left: 6px solid ${isCritical ? '#e03131' : '#f59f00'};">
|
|||
|
|
<strong>${level}:</strong> ${title}
|
|||
|
|
${message ? `<div class="small mt-1">${message}</div>` : ''}
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
container.classList.remove('d-none');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load case customer alerts:', 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');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function forceCaseTabActivation(tabId) {
|
|||
|
|
if (!tabId) return;
|
|||
|
|
|
|||
|
|
const tabContent = document.getElementById('caseTabsContent');
|
|||
|
|
const targetPane = document.getElementById(tabId);
|
|||
|
|
if (!tabContent || !targetPane) return;
|
|||
|
|
|
|||
|
|
tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => {
|
|||
|
|
pane.classList.remove('show', 'active');
|
|||
|
|
pane.style.display = 'none';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
targetPane.classList.add('show', 'active');
|
|||
|
|
targetPane.style.display = 'block';
|
|||
|
|
|
|||
|
|
const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]');
|
|||
|
|
tabButtons.forEach((btn) => {
|
|||
|
|
btn.classList.toggle('active', btn.getAttribute('data-bs-target') === `#${tabId}`);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.moduleDisplayNames = {
|
|||
|
|
'relations': 'Relationer',
|
|||
|
|
'call-history': 'Opkaldshistorik',
|
|||
|
|
'files': 'Filer',
|
|||
|
|
'emails': 'E-mails',
|
|||
|
|
'pipeline': 'Salgspipeline',
|
|||
|
|
'hardware': 'Hardware',
|
|||
|
|
'locations': 'Lokationer',
|
|||
|
|
'contacts': 'Kontakter',
|
|||
|
|
'customers': 'Kunder',
|
|||
|
|
'tags': 'Tags',
|
|||
|
|
'wiki': 'Wiki',
|
|||
|
|
'todo-steps': 'Todo-opgaver',
|
|||
|
|
'time': 'Tid',
|
|||
|
|
'timetracking': 'Tidsforbrug',
|
|||
|
|
'solution': 'Løsning',
|
|||
|
|
'sales': 'Varekøb & salg',
|
|||
|
|
'subscription': 'Abonnement',
|
|||
|
|
'reminders': 'Påmindelser',
|
|||
|
|
'calendar': 'Kalender'
|
|||
|
|
};
|
|||
|
|
caseTypeModuleDefaults = window.caseTypeModuleDefaults || {};
|
|||
|
|
|
|||
|
|
// Modal instances
|
|||
|
|
let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance, caseAnyDeskModal;
|
|||
|
|
let currentContactInfo = null;
|
|||
|
|
|
|||
|
|
// Initialize everything when DOM is ready
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
markCaseAsRecentlyOpened();
|
|||
|
|
hydrateTopbarStatusOptions();
|
|||
|
|
loadCaseCustomerTopAlerts();
|
|||
|
|
applyTopbarImportanceBubbles();
|
|||
|
|
// Initialize modals
|
|||
|
|
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
|
|||
|
|
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
|
|||
|
|
relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
|
|||
|
|
contactInfoModal = new bootstrap.Modal(document.getElementById('contactInfoModal'));
|
|||
|
|
createRelatedCaseModalInstance = new bootstrap.Modal(document.getElementById('createRelatedCaseModal'));
|
|||
|
|
const caseAnyDeskEl = document.getElementById('caseAnyDeskModal');
|
|||
|
|
if (caseAnyDeskEl) {
|
|||
|
|
caseAnyDeskModal = new bootstrap.Modal(caseAnyDeskEl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Setup search handlers
|
|||
|
|
setupContactSearch();
|
|||
|
|
setupCustomerSearch();
|
|||
|
|
setupRelationSearch();
|
|||
|
|
updateRelationTypeHint();
|
|||
|
|
updateNewCaseRelationTypeHint();
|
|||
|
|
|
|||
|
|
// Initialize all tooltips on the page
|
|||
|
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
|||
|
|
bootstrap.Tooltip.getOrCreateInstance(el, { html: true, container: 'body' });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
Promise.all([loadModulePrefs(), loadCaseTypeModuleDefaultsSetting()]).then(() => applyViewFromTags());
|
|||
|
|
|
|||
|
|
// Set default context for keyboard shortcuts (Option+Shift+T)
|
|||
|
|
if (window.setTagPickerContext) {
|
|||
|
|
window.setTagPickerContext('case', {{ case.id }}, () => syncCaseTagsUi());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Load Hardware & Locations
|
|||
|
|
loadCaseHardware();
|
|||
|
|
loadCaseLocations();
|
|||
|
|
loadCaseWiki();
|
|||
|
|
loadTodoSteps();
|
|||
|
|
loadCaseTagsModule();
|
|||
|
|
loadCaseTagSuggestions();
|
|||
|
|
|
|||
|
|
// Keep suggestions fresh while user works on the case.
|
|||
|
|
setInterval(loadCaseTagSuggestions, 30000);
|
|||
|
|
|
|||
|
|
const wikiSearchInput = document.getElementById('wikiSearchInput');
|
|||
|
|
if (wikiSearchInput) {
|
|||
|
|
wikiSearchInput.addEventListener('input', () => {
|
|||
|
|
clearTimeout(wikiSearchTimeout);
|
|||
|
|
wikiSearchTimeout = setTimeout(() => {
|
|||
|
|
loadCaseWiki(wikiSearchInput.value || '');
|
|||
|
|
}, 300);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const todoForm = document.getElementById('todoStepForm');
|
|||
|
|
if (todoForm) {
|
|||
|
|
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) => {
|
|||
|
|
const targetSelector = event?.target?.getAttribute('data-bs-target') || '';
|
|||
|
|
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
|
|||
|
|
|
|||
|
|
forceCaseTabActivation(tabId);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
|
|||
|
|
await loadVarekobSalg();
|
|||
|
|
} else if (tabId === 'timetracking' && typeof loadTimeTrackingTab === 'function') {
|
|||
|
|
await loadTimeTrackingTab();
|
|||
|
|
} else if (tabId === 'subscription' && typeof loadSubscriptionForCase === 'function') {
|
|||
|
|
await loadSubscriptionForCase();
|
|||
|
|
} else if (tabId === 'reminders') {
|
|||
|
|
if (typeof loadReminders === 'function') await loadReminders();
|
|||
|
|
if (typeof loadCaseCalendar === 'function') await loadCaseCalendar();
|
|||
|
|
}
|
|||
|
|
} catch (tabLoadError) {
|
|||
|
|
console.error('Tab data reload failed:', tabLoadError);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
caseTabs.addEventListener('click', (event) => {
|
|||
|
|
const btn = event.target.closest('[data-bs-target]');
|
|||
|
|
if (!btn) return;
|
|||
|
|
const targetSelector = btn.getAttribute('data-bs-target') || '';
|
|||
|
|
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
|
|||
|
|
if (tabId) {
|
|||
|
|
setTimeout(() => forceCaseTabActivation(tabId), 0);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
forceCaseTabActivation('details');
|
|||
|
|
|
|||
|
|
// Focus on title when create modal opens
|
|||
|
|
const createModalEl = document.getElementById('createRelatedCaseModal');
|
|||
|
|
if (createModalEl) {
|
|||
|
|
createModalEl.addEventListener('shown.bs.modal', function () {
|
|||
|
|
document.getElementById('newCaseTitle').focus();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Show modal functions
|
|||
|
|
function showContactSearch() {
|
|||
|
|
contactSearchModal.show();
|
|||
|
|
setTimeout(() => document.getElementById('contactSearch').focus(), 300);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showCustomerSearch(mode = 'link') {
|
|||
|
|
customerSearchMode = mode === 'replace' ? 'replace' : 'link';
|
|||
|
|
const title = document.getElementById('customerSearchModalTitle');
|
|||
|
|
if (title) {
|
|||
|
|
title.textContent = customerSearchMode === 'replace' ? 'Skift kunde på sag' : 'Søg kunde';
|
|||
|
|
}
|
|||
|
|
customerSearchModal.show();
|
|||
|
|
setTimeout(() => document.getElementById('customerSearch').focus(), 300);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showRelationModal() {
|
|||
|
|
const modalEl = document.getElementById('relationModal');
|
|||
|
|
if (!modalEl) {
|
|||
|
|
console.error('relationModal element not found');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!relationModal) {
|
|||
|
|
relationModal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
relationModal.show();
|
|||
|
|
updateRelationTypeHint();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const input = document.getElementById('relationCaseSearch');
|
|||
|
|
if (input) input.focus();
|
|||
|
|
}, 300);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openAuthenticatedPrintView(url, fallbackTitle) {
|
|||
|
|
const popup = window.open('', '_blank', 'noopener');
|
|||
|
|
if (!popup) {
|
|||
|
|
alert('Browser blokerede popup-vinduet. Tillad popups for at printe.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
popup.document.write('<!doctype html><html><head><meta charset="utf-8"><title>Loader...</title></head><body style="font-family:Segoe UI,sans-serif;padding:20px;">Henter printvisning...</body></html>');
|
|||
|
|
popup.document.close();
|
|||
|
|
|
|||
|
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
|||
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(url, {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers,
|
|||
|
|
credentials: 'include'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let detail = `Kunne ikke hente printvisning (${res.status})`;
|
|||
|
|
try {
|
|||
|
|
const payload = await res.json();
|
|||
|
|
if (payload?.detail) detail = payload.detail;
|
|||
|
|
} catch (_) {
|
|||
|
|
}
|
|||
|
|
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:#b42318;">${escapeHtml(detail)}</div>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const htmlContent = await res.text();
|
|||
|
|
popup.document.open();
|
|||
|
|
popup.document.write(htmlContent);
|
|||
|
|
popup.document.close();
|
|||
|
|
if (fallbackTitle && !popup.document.title) {
|
|||
|
|
popup.document.title = fallbackTitle;
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
popup.document.body.innerHTML = `<div style="font-family:Segoe UI,sans-serif;padding:20px;color:#b42318;">Fejl ved hentning af printvisning: ${escapeHtml(error?.message || 'Ukendt fejl')}</div>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCaseWorkOrderPrint() {
|
|||
|
|
window.open(`/sag/${caseId}/work-orders/print`, '_blank', 'noopener');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCaseHardwareLabelsPrint() {
|
|||
|
|
window.open(`/sag/${caseId}/labels/hardware/print`, '_blank', 'noopener');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCaseHardwareLabelsPrintDirect() {
|
|||
|
|
window.open(`/sag/${caseId}/labels/hardware/print?auto_print=1`, '_blank', 'noopener');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function sendCaseHardwareLabelsToPrinter(hardwareId = null) {
|
|||
|
|
try {
|
|||
|
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
|||
|
|
const headers = { 'Content-Type': 'application/json' };
|
|||
|
|
if (token) {
|
|||
|
|
headers.Authorization = `Bearer ${token}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const requestPayload = {};
|
|||
|
|
if (hardwareId !== null && hardwareId !== undefined) {
|
|||
|
|
const parsedHardwareId = Number(hardwareId);
|
|||
|
|
if (!Number.isFinite(parsedHardwareId)) {
|
|||
|
|
showNotification('Ugyldigt hardware-id', 'error');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
requestPayload.hardware_id = parsedHardwareId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/labels/hardware/print-direct`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers,
|
|||
|
|
credentials: 'include',
|
|||
|
|
body: JSON.stringify(requestPayload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
let detail = `Print fejlede (${response.status})`;
|
|||
|
|
try {
|
|||
|
|
const payload = await response.json();
|
|||
|
|
if (payload && payload.detail) detail = payload.detail;
|
|||
|
|
} catch (_) {}
|
|||
|
|
showNotification(detail, 'error');
|
|||
|
|
// Fallback: open browser print view if direct printer is unavailable.
|
|||
|
|
openCaseHardwareLabelsPrintDirect();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = await response.json().catch(() => ({}));
|
|||
|
|
const printed = payload?.printed || 0;
|
|||
|
|
if (hardwareId !== null && hardwareId !== undefined) {
|
|||
|
|
showNotification('Label sendt til printer', 'success');
|
|||
|
|
} else {
|
|||
|
|
showNotification(`Sendte ${printed} labels til printer`, 'success');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
showNotification(`Print fejlede: ${error?.message || 'ukendt fejl'}`, 'error');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showContactInfoModal(el) {
|
|||
|
|
currentContactInfo = {
|
|||
|
|
id: el.dataset.contactId,
|
|||
|
|
name: el.dataset.name || '-',
|
|||
|
|
title: el.dataset.title || '-',
|
|||
|
|
company: el.dataset.company || '-',
|
|||
|
|
email: el.dataset.email || '-',
|
|||
|
|
phone: el.dataset.phone || '-',
|
|||
|
|
mobile: el.dataset.mobile || '-',
|
|||
|
|
role: el.dataset.role || '-',
|
|||
|
|
isPrimary: el.dataset.isPrimary === 'true'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
document.getElementById('contactInfoName').textContent = currentContactInfo.name;
|
|||
|
|
document.getElementById('contactInfoTitle').textContent = currentContactInfo.title || '-';
|
|||
|
|
document.getElementById('contactInfoCompany').textContent = currentContactInfo.company || '-';
|
|||
|
|
document.getElementById('contactInfoEmail').textContent = currentContactInfo.email || '-';
|
|||
|
|
document.getElementById('contactInfoPhone').innerHTML = renderCasePhone(currentContactInfo.phone);
|
|||
|
|
document.getElementById('contactInfoMobile').innerHTML = renderCaseMobile(currentContactInfo.mobile, currentContactInfo.name);
|
|||
|
|
document.getElementById('contactInfoRole').textContent = currentContactInfo.role || '-';
|
|||
|
|
|
|||
|
|
const primaryBadge = document.getElementById('contactInfoPrimary');
|
|||
|
|
if (currentContactInfo.isPrimary) {
|
|||
|
|
primaryBadge.classList.remove('d-none');
|
|||
|
|
} else {
|
|||
|
|
primaryBadge.classList.add('d-none');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
contactInfoModal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCasePhone(number) {
|
|||
|
|
const clean = String(number || '').trim();
|
|||
|
|
if (!clean || clean === '-') return '-';
|
|||
|
|
return `<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCaseMobile(number, name) {
|
|||
|
|
const clean = String(number || '').trim();
|
|||
|
|
if (!clean || clean === '-') return '-';
|
|||
|
|
return `
|
|||
|
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|||
|
|
<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '${escapeHtml(name || '')}', ${currentContactInfo?.id || 'null'})">SMS</button>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCurrentContactPreferredNumber() {
|
|||
|
|
if (!currentContactInfo) return '';
|
|||
|
|
const mobile = String(currentContactInfo.mobile || '').trim();
|
|||
|
|
const phone = String(currentContactInfo.phone || '').trim();
|
|||
|
|
return mobile && mobile !== '-' ? mobile : (phone && phone !== '-' ? phone : '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function callCurrentContactFromInfo() {
|
|||
|
|
const number = getCurrentContactPreferredNumber();
|
|||
|
|
if (!number) {
|
|||
|
|
alert('Ingen telefon eller mobil på kontakten');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await ringOutFromCase(number, {
|
|||
|
|
logToCase: true,
|
|||
|
|
contactName: currentContactInfo?.name || ''
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function smsCurrentContactFromInfo() {
|
|||
|
|
if (!currentContactInfo) return;
|
|||
|
|
const number = String(currentContactInfo.mobile || currentContactInfo.phone || '').trim();
|
|||
|
|
if (!number || number === '-') {
|
|||
|
|
alert('Ingen mobil/telefon på kontakten');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
openSmsPrompt(number, currentContactInfo.name || '', currentContactInfo.id || null);
|
|||
|
|
logCaseSystemComment(`💬 SMS-vindue åbnet til ${currentContactInfo.name || 'kontakt'} (${number})`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function emailCurrentContactFromInfo() {
|
|||
|
|
if (!currentContactInfo) return;
|
|||
|
|
const email = String(currentContactInfo.email || '').trim();
|
|||
|
|
if (!email || email === '-') {
|
|||
|
|
alert('Ingen e-mail på kontakten');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
openCaseEmailTab();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const toInput = document.getElementById('caseEmailTo');
|
|||
|
|
if (toInput) toInput.value = email;
|
|||
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|||
|
|
if (composeModalEl) {
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
|
|||
|
|
}
|
|||
|
|
}, 150);
|
|||
|
|
logCaseSystemComment(`✉️ Email-kladde åbnet til ${currentContactInfo.name || 'kontakt'} (${email})`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function callMainContactFromModal() {
|
|||
|
|
const number = {{ (hovedkontakt.mobile or hovedkontakt.phone or '')|tojson if hovedkontakt else "''" }};
|
|||
|
|
const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson if hovedkontakt else "''" }};
|
|||
|
|
if (!number) {
|
|||
|
|
alert('Ingen telefon eller mobil på hovedkontakt');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await ringOutFromCase(number, { logToCase: true, contactName: name });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function smsMainContactFromModal() {
|
|||
|
|
{% if hovedkontakt %}
|
|||
|
|
const number = {{ (hovedkontakt.mobile or hovedkontakt.phone or '')|tojson }};
|
|||
|
|
const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson }};
|
|||
|
|
const contactId = {{ hovedkontakt.id|default('null') }};
|
|||
|
|
if (!number) {
|
|||
|
|
alert('Ingen mobil/telefon på hovedkontakt');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
openSmsPrompt(number, name, contactId);
|
|||
|
|
logCaseSystemComment(`💬 SMS-vindue åbnet til ${name} (${number})`);
|
|||
|
|
{% endif %}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function emailMainContactFromModal() {
|
|||
|
|
{% if hovedkontakt %}
|
|||
|
|
const email = {{ (hovedkontakt.email or '')|tojson }};
|
|||
|
|
const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson }};
|
|||
|
|
if (!email) {
|
|||
|
|
alert('Ingen e-mail på hovedkontakt');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
openCaseEmailTab();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const toInput = document.getElementById('caseEmailTo');
|
|||
|
|
if (toInput) toInput.value = email;
|
|||
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|||
|
|
if (composeModalEl) {
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
|
|||
|
|
}
|
|||
|
|
}, 150);
|
|||
|
|
logCaseSystemComment(`✉️ Email-kladde åbnet til ${name} (${email})`);
|
|||
|
|
{% endif %}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openContactRoleFromInfo() {
|
|||
|
|
if (!currentContactInfo) return;
|
|||
|
|
contactInfoModal.hide();
|
|||
|
|
openContactRoleModal(
|
|||
|
|
currentContactInfo.id,
|
|||
|
|
currentContactInfo.name,
|
|||
|
|
currentContactInfo.role || 'Kontakt',
|
|||
|
|
currentContactInfo.isPrimary
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showCreateRelatedModal() {
|
|||
|
|
if (!createRelatedCaseModalInstance) {
|
|||
|
|
const modalEl = document.getElementById('createRelatedCaseModal');
|
|||
|
|
if (!modalEl) {
|
|||
|
|
console.error('createRelatedCaseModal element not found');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
createRelatedCaseModalInstance = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|||
|
|
}
|
|||
|
|
createRelatedCaseModalInstance.show();
|
|||
|
|
updateNewCaseRelationTypeHint();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function relationTypeMeaning(type) {
|
|||
|
|
const map = {
|
|||
|
|
'Relateret til': {
|
|||
|
|
icon: '🔗',
|
|||
|
|
text: 'Koblet til: Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
|
|||
|
|
},
|
|||
|
|
'Afledt af': {
|
|||
|
|
icon: '↪',
|
|||
|
|
text: 'Kommer fra: Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
|
|||
|
|
},
|
|||
|
|
'Årsag til': {
|
|||
|
|
icon: '➡',
|
|||
|
|
text: 'Skaber følge-sag: Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).'
|
|||
|
|
},
|
|||
|
|
'Blokkerer': {
|
|||
|
|
icon: '⛔',
|
|||
|
|
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
return map[type] || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateRelationTypeHint() {
|
|||
|
|
const select = document.getElementById('relationTypeSelect');
|
|||
|
|
const hint = document.getElementById('relationTypeHint');
|
|||
|
|
if (!select || !hint) return;
|
|||
|
|
|
|||
|
|
const meaning = relationTypeMeaning(select.value);
|
|||
|
|
if (!meaning) {
|
|||
|
|
hint.style.display = 'none';
|
|||
|
|
hint.innerHTML = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
hint.style.display = 'block';
|
|||
|
|
hint.innerHTML = `<strong>${meaning.icon} Betydning:</strong> ${meaning.text}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateNewCaseRelationTypeHint() {
|
|||
|
|
const select = document.getElementById('newCaseRelationType');
|
|||
|
|
const hint = document.getElementById('newCaseRelationTypeHint');
|
|||
|
|
if (!select || !hint) return;
|
|||
|
|
|
|||
|
|
const selected = select.value;
|
|||
|
|
if (selected === 'Afledt af') {
|
|||
|
|
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som kommer fra den nye sag.';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (selected === 'Årsag til') {
|
|||
|
|
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som at den skaber den nye følge-sag.';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (selected === 'Blokkerer') {
|
|||
|
|
hint.innerHTML = '<strong>⛔ Effekt:</strong> Nuværende sag markeres som blokering for den nye sag.';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed (Koblet til).';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createRelatedCase() {
|
|||
|
|
const title = document.getElementById('newCaseTitle').value;
|
|||
|
|
const relationType = document.getElementById('newCaseRelationType').value;
|
|||
|
|
const description = document.getElementById('newCaseDescription').value;
|
|||
|
|
const customerId = (caseCustomerId || wikiCustomerId || null);
|
|||
|
|
|
|||
|
|
if (!title) {
|
|||
|
|
alert('Titel er påkrævet');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!customerId) {
|
|||
|
|
alert('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1. Create the new case
|
|||
|
|
try {
|
|||
|
|
const caseResponse = await fetch('/api/v1/sag', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
titel: title,
|
|||
|
|
beskrivelse: description,
|
|||
|
|
customer_id: customerId,
|
|||
|
|
status: 'åben'
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!caseResponse.ok) {
|
|||
|
|
const payload = await caseResponse.json().catch(() => ({}));
|
|||
|
|
throw new Error(payload.detail || 'Kunne ikke oprette sag');
|
|||
|
|
}
|
|||
|
|
const newCase = await caseResponse.json();
|
|||
|
|
|
|||
|
|
// 2. Create the relation
|
|||
|
|
const relationResponse = await fetch(`/api/v1/sag/${caseId}/relationer`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
målsag_id: newCase.id,
|
|||
|
|
relationstype: relationType
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!relationResponse.ok) {
|
|||
|
|
const payload = await relationResponse.json().catch(() => ({}));
|
|||
|
|
throw new Error(payload.detail || 'Kunne ikke oprette relation');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Reload to show new relation
|
|||
|
|
window.location.reload();
|
|||
|
|
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error creating related case:', err);
|
|||
|
|
alert('Der opstod en fejl: ' + err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function confirmDeleteCase() {
|
|||
|
|
if(confirm('Slet denne sag?')) {
|
|||
|
|
fetch('/api/v1/sag/{{ case.id }}', {method: 'DELETE'})
|
|||
|
|
.then(() => window.location='/sag');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Contact Search
|
|||
|
|
function setupContactSearch() {
|
|||
|
|
const contactSearchInput = document.getElementById('contactSearch');
|
|||
|
|
contactSearchInput.addEventListener('input', function(e) {
|
|||
|
|
clearTimeout(contactSearchTimeout);
|
|||
|
|
const query = e.target.value.trim();
|
|||
|
|
|
|||
|
|
if (query.length < 2) {
|
|||
|
|
document.getElementById('contactSearchResults').innerHTML = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
contactSearchTimeout = setTimeout(async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`);
|
|||
|
|
const contacts = await response.json();
|
|||
|
|
|
|||
|
|
const resultsDiv = document.getElementById('contactSearchResults');
|
|||
|
|
if (contacts.length === 0) {
|
|||
|
|
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
|
|||
|
|
} else {
|
|||
|
|
resultsDiv.innerHTML = contacts.map(c => `
|
|||
|
|
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
|
|||
|
|
onclick="addContact(${caseId}, ${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
|
|||
|
|
<strong>${c.first_name} ${c.last_name}</strong>
|
|||
|
|
<div class="small text-muted">${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error searching contacts:', err);
|
|||
|
|
}
|
|||
|
|
}, 300);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addContact(caseId, contactId, contactName) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/contacts`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({contact_id: contactId, role: 'Kontakt'})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
contactSearchModal.hide();
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
const error = await response.json();
|
|||
|
|
alert(`Fejl: ${error.detail}`);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
alert('Fejl ved tilføjelse af kontakt: ' + err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function removeContact(caseId, contactId) {
|
|||
|
|
if (confirm('Fjern denne kontakt fra sagen?')) {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, {method: 'DELETE'});
|
|||
|
|
if (response.ok) {
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert('Fejl ved fjernelse af kontakt');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Customer Search
|
|||
|
|
function setupCustomerSearch() {
|
|||
|
|
const customerSearchInput = document.getElementById('customerSearch');
|
|||
|
|
customerSearchInput.addEventListener('input', function(e) {
|
|||
|
|
clearTimeout(customerSearchTimeout);
|
|||
|
|
const query = e.target.value.trim();
|
|||
|
|
|
|||
|
|
if (query.length < 2) {
|
|||
|
|
document.getElementById('customerSearchResults').innerHTML = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
customerSearchTimeout = setTimeout(async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
|
|||
|
|
const customers = await response.json();
|
|||
|
|
|
|||
|
|
const resultsDiv = document.getElementById('customerSearchResults');
|
|||
|
|
if (customers.length === 0) {
|
|||
|
|
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kunder fundet</div>';
|
|||
|
|
} else {
|
|||
|
|
resultsDiv.innerHTML = customers.map(c => `
|
|||
|
|
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
|
|||
|
|
onclick="selectCustomerFromSearch(${caseId}, ${c.id})">
|
|||
|
|
<strong>${c.name}</strong>
|
|||
|
|
<div class="small text-muted">${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error searching customers:', err);
|
|||
|
|
}
|
|||
|
|
}, 300);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function selectCustomerFromSearch(caseId, customerId, customerName = '') {
|
|||
|
|
if (customerSearchMode === 'replace') {
|
|||
|
|
await replaceCaseCustomer(caseId, customerId, customerName);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await addCustomer(caseId, customerId, customerName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function replaceCaseCustomer(caseId, customerId, customerName) {
|
|||
|
|
try {
|
|||
|
|
let response = await fetch(`/api/v1/sag/${caseId}/customer/replace`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({customer_id: customerId})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Backwards compatibility with older API nodes.
|
|||
|
|
if (response.status === 404 || response.status === 405) {
|
|||
|
|
response = await fetch(`/api/v1/sag/${caseId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({customer_id: customerId})
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
customerSearchModal.hide();
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
const error = await response.json().catch(() => ({}));
|
|||
|
|
alert(`Fejl ved skift af kunde: ${error.detail || response.statusText || 'Ukendt fejl'}`);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
alert('Fejl ved skift af kunde: ' + err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addCustomer(caseId, customerId, customerName) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/customers`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({customer_id: customerId, role: 'Kunde'})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
customerSearchModal.hide();
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
const error = await response.json();
|
|||
|
|
alert(`Fejl: ${error.detail}`);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
alert('Fejl ved tilføjelse af kunde: ' + err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function removeCustomer(caseId, customerId) {
|
|||
|
|
if (confirm('Fjern denne kunde fra sagen?')) {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, {method: 'DELETE'});
|
|||
|
|
if (response.ok) {
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert('Fejl ved fjernelse af kunde');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Relation Search - Enhanced version
|
|||
|
|
let currentFocusIndex = -1;
|
|||
|
|
let searchResults = [];
|
|||
|
|
|
|||
|
|
function setupRelationSearch() {
|
|||
|
|
const relationSearchInput = document.getElementById('relationCaseSearch');
|
|||
|
|
|
|||
|
|
// Input handler
|
|||
|
|
relationSearchInput.addEventListener('input', function(e) {
|
|||
|
|
clearTimeout(relationSearchTimeout);
|
|||
|
|
const query = e.target.value.trim();
|
|||
|
|
currentFocusIndex = -1;
|
|||
|
|
|
|||
|
|
if (query.length < 2) {
|
|||
|
|
document.getElementById('relationSearchResults').innerHTML = '';
|
|||
|
|
document.getElementById('relationSearchResults').style.display = 'none';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
relationSearchTimeout = setTimeout(async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query)}`);
|
|||
|
|
const cases = await response.json();
|
|||
|
|
searchResults = cases.filter(c => c.id !== caseId);
|
|||
|
|
|
|||
|
|
renderRelationSearchResults(searchResults);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error searching cases:', err);
|
|||
|
|
}
|
|||
|
|
}, 200);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Keyboard navigation
|
|||
|
|
relationSearchInput.addEventListener('keydown', function(e) {
|
|||
|
|
const resultsDiv = document.getElementById('relationSearchResults');
|
|||
|
|
const items = resultsDiv.querySelectorAll('.relation-search-item');
|
|||
|
|
|
|||
|
|
if (e.key === 'ArrowDown') {
|
|||
|
|
e.preventDefault();
|
|||
|
|
currentFocusIndex = (currentFocusIndex + 1) % items.length;
|
|||
|
|
updateFocusedItem(items);
|
|||
|
|
} else if (e.key === 'ArrowUp') {
|
|||
|
|
e.preventDefault();
|
|||
|
|
currentFocusIndex = currentFocusIndex <= 0 ? items.length - 1 : currentFocusIndex - 1;
|
|||
|
|
updateFocusedItem(items);
|
|||
|
|
} else if (e.key === 'Enter') {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (currentFocusIndex >= 0 && currentFocusIndex < items.length) {
|
|||
|
|
items[currentFocusIndex].click();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateFocusedItem(items) {
|
|||
|
|
items.forEach((item, index) => {
|
|||
|
|
if (index === currentFocusIndex) {
|
|||
|
|
item.classList.add('active');
|
|||
|
|
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|||
|
|
} else {
|
|||
|
|
item.classList.remove('active');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderRelationSearchResults(cases) {
|
|||
|
|
const resultsDiv = document.getElementById('relationSearchResults');
|
|||
|
|
|
|||
|
|
if (cases.length === 0) {
|
|||
|
|
resultsDiv.innerHTML = '<div class="p-3 text-muted text-center"><i class="bi bi-search me-2"></i>Ingen sager fundet</div>';
|
|||
|
|
resultsDiv.style.display = 'block';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Group by status
|
|||
|
|
const grouped = {};
|
|||
|
|
cases.forEach(c => {
|
|||
|
|
const status = c.status || 'ukendt';
|
|||
|
|
if (!grouped[status]) grouped[status] = [];
|
|||
|
|
grouped[status].push(c);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let html = '<div class="list-group list-group-flush">';
|
|||
|
|
|
|||
|
|
// Sort status groups: åben first, then others
|
|||
|
|
const statusOrder = ['åben', 'under behandling', 'afventer', 'løst', 'lukket'];
|
|||
|
|
const sortedStatuses = Object.keys(grouped).sort((a, b) => {
|
|||
|
|
const aIndex = statusOrder.indexOf(a);
|
|||
|
|
const bIndex = statusOrder.indexOf(b);
|
|||
|
|
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
|
|||
|
|
if (aIndex === -1) return 1;
|
|||
|
|
if (bIndex === -1) return -1;
|
|||
|
|
return aIndex - bIndex;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
sortedStatuses.forEach(status => {
|
|||
|
|
const statusCases = grouped[status];
|
|||
|
|
|
|||
|
|
// Status group header
|
|||
|
|
html += `
|
|||
|
|
<div class="list-group-item bg-light" style="padding: 0.5rem 1rem; font-weight: 600; font-size: 0.85rem; color: var(--text-secondary);">
|
|||
|
|
<span class="status-badge status-${status}">${status}</span>
|
|||
|
|
<span class="badge bg-secondary float-end">${statusCases.length}</span>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
statusCases.forEach(c => {
|
|||
|
|
const createdDate = c.created_at ? new Date(c.created_at).toLocaleDateString('da-DK') : 'N/A';
|
|||
|
|
const beskrivelse = c.beskrivelse ? c.beskrivelse.substring(0, 80) + '...' : '';
|
|||
|
|
const customerName = c.customer_name || '';
|
|||
|
|
const safeTitle = (c.titel || '').replace(/"/g, '"').replace(/'/g, ''');
|
|||
|
|
const safeCustomer = customerName.replace(/"/g, '"').replace(/'/g, ''');
|
|||
|
|
|
|||
|
|
html += `
|
|||
|
|
<div class="list-group-item list-group-item-action relation-search-item"
|
|||
|
|
style="cursor: pointer; padding: 0.75rem 1rem;"
|
|||
|
|
onclick="selectRelationCase(${c.id}, '${safeTitle}', '${safeCustomer}', '${status}');"
|
|||
|
|
data-case-id="${c.id}">
|
|||
|
|
<div class="d-flex justify-content-between align-items-start">
|
|||
|
|
<div style="flex: 1;">
|
|||
|
|
<div class="d-flex align-items-center gap-2 mb-1">
|
|||
|
|
<span class="badge bg-primary" style="font-size: 0.75rem;">#${c.id}</span>
|
|||
|
|
<strong style="font-size: 0.95rem;">${escapeHtml(c.titel)}</strong>
|
|||
|
|
</div>
|
|||
|
|
${c.customer_name ? `
|
|||
|
|
<div class="small text-muted mb-1">
|
|||
|
|
<i class="bi bi-building me-1"></i>${escapeHtml(c.customer_name)}
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
${beskrivelse ? `
|
|||
|
|
<div class="small text-muted" style="font-size: 0.8rem;">${escapeHtml(beskrivelse)}</div>
|
|||
|
|
` : ''}
|
|||
|
|
</div>
|
|||
|
|
<div class="text-end" style="min-width: 100px;">
|
|||
|
|
<div class="small text-muted">${createdDate}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
html += '</div>';
|
|||
|
|
resultsDiv.innerHTML = html;
|
|||
|
|
resultsDiv.style.display = 'block';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeHtml(text) {
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.textContent = text;
|
|||
|
|
return div.innerHTML;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectRelationCase(caseIdValue, caseTitel, customerName, status) {
|
|||
|
|
selectedRelationCaseId = caseIdValue;
|
|||
|
|
|
|||
|
|
// Update preview
|
|||
|
|
const previewDiv = document.getElementById('selectedCasePreview');
|
|||
|
|
const titleDiv = document.getElementById('selectedCaseTitle');
|
|||
|
|
|
|||
|
|
titleDiv.innerHTML = `
|
|||
|
|
<div class="d-flex align-items-center gap-2 mb-1">
|
|||
|
|
<span class="badge bg-primary">#${caseIdValue}</span>
|
|||
|
|
<strong>${escapeHtml(caseTitel)}</strong>
|
|||
|
|
<span class="status-badge status-${status}">${status}</span>
|
|||
|
|
</div>
|
|||
|
|
${customerName ? `<div class="small"><i class="bi bi-building me-1"></i>${escapeHtml(customerName)}</div>` : ''}
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
previewDiv.style.display = 'block';
|
|||
|
|
document.getElementById('relationSearchResults').innerHTML = '';
|
|||
|
|
document.getElementById('relationSearchResults').style.display = 'none';
|
|||
|
|
document.getElementById('relationCaseSearch').value = '';
|
|||
|
|
|
|||
|
|
// Enable add button
|
|||
|
|
updateAddRelationButton();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearSelectedRelationCase() {
|
|||
|
|
selectedRelationCaseId = null;
|
|||
|
|
document.getElementById('selectedCasePreview').style.display = 'none';
|
|||
|
|
document.getElementById('relationCaseSearch').value = '';
|
|||
|
|
document.getElementById('relationCaseSearch').focus();
|
|||
|
|
updateAddRelationButton();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateAddRelationButton() {
|
|||
|
|
const btn = document.getElementById('addRelationBtn');
|
|||
|
|
const relationType = document.getElementById('relationTypeSelect').value;
|
|||
|
|
btn.disabled = !selectedRelationCaseId || !relationType;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addRelation() {
|
|||
|
|
const relationType = document.getElementById('relationTypeSelect').value;
|
|||
|
|
const btn = document.getElementById('addRelationBtn');
|
|||
|
|
|
|||
|
|
if (!selectedRelationCaseId) {
|
|||
|
|
alert('Vælg en sag først');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!relationType) {
|
|||
|
|
alert('Vælg en relationstype');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Disable button during request
|
|||
|
|
btn.disabled = true;
|
|||
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Tilføjer...';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/relationer`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
målsag_id: selectedRelationCaseId,
|
|||
|
|
relationstype: relationType
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
selectedRelationCaseId = null;
|
|||
|
|
relationModal.hide();
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
const error = await response.json();
|
|||
|
|
alert(`Fejl: ${error.detail}`);
|
|||
|
|
btn.disabled = false;
|
|||
|
|
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
alert('Fejl ved tilføjelse af relation: ' + err.message);
|
|||
|
|
btn.disabled = false;
|
|||
|
|
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteRelation(relationId) {
|
|||
|
|
if (confirm('Fjern denne relation?')) {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/relationer/${relationId}`, {method: 'DELETE'});
|
|||
|
|
if (response.ok) {
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert('Fejl ved fjernelse af relation');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Hardware Handling ============
|
|||
|
|
async function loadCaseHardware() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let message = 'Kunne ikke hente hardware.';
|
|||
|
|
try {
|
|||
|
|
const err = await res.json();
|
|||
|
|
if (err?.detail) {
|
|||
|
|
message = err.detail;
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
}
|
|||
|
|
throw new Error(message);
|
|||
|
|
}
|
|||
|
|
const hardware = await res.json();
|
|||
|
|
if (!Array.isArray(hardware)) {
|
|||
|
|
throw new Error('Uventet svar fra serveren ved hardware-hentning.');
|
|||
|
|
}
|
|||
|
|
const container = document.getElementById('hardware-list');
|
|||
|
|
|
|||
|
|
if (hardware.length === 0) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen hardware tilknyttet</div>';
|
|||
|
|
setModuleContentState('hardware', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = `
|
|||
|
|
<div class="hardware-list-header">
|
|||
|
|
<span>Enhed</span>
|
|||
|
|
<span>SN</span>
|
|||
|
|
<span>Label</span>
|
|||
|
|
<span>Slet</span>
|
|||
|
|
</div>
|
|||
|
|
${hardware.map(h => `
|
|||
|
|
<div class="hardware-row">
|
|||
|
|
<div>
|
|||
|
|
<a href="/hardware/${h.id}" class="text-decoration-none fw-semibold">
|
|||
|
|
${h.brand} ${h.model}
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
<small>${h.serial_number || '-'}</small>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="sendCaseHardwareLabelsToPrinter(${h.id})" title="Print label for denne hardware">
|
|||
|
|
<i class="bi bi-printer"></i>
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-delete" onclick="unlinkHardware(${h.id})" title="Slet">
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
`).join('')}
|
|||
|
|
`;
|
|||
|
|
setModuleContentState('hardware', true);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Error loading hardware:", e);
|
|||
|
|
const message = (e?.message || '').trim() || 'Fejl ved hentning';
|
|||
|
|
document.getElementById('hardware-list').innerHTML = `<div class="p-3 text-danger text-center">${escapeHtml(message)}</div>`;
|
|||
|
|
setModuleContentState('hardware', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function promptLinkHardware() {
|
|||
|
|
const id = prompt("Indtast Hardware ID:");
|
|||
|
|
if (!id) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({ hardware_id: parseInt(id) })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) throw await res.json();
|
|||
|
|
loadCaseHardware();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert("Fejl: " + (e.detail || e.message));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function unlinkHardware(hwId) {
|
|||
|
|
if(!confirm("Fjern link til dette hardware?")) return;
|
|||
|
|
try {
|
|||
|
|
await fetch(`/api/v1/sag/{{ case.id }}/hardware/${hwId}`, { method: 'DELETE' });
|
|||
|
|
loadCaseHardware();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert("Fejl ved sletning");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Location Handling ============
|
|||
|
|
async function loadCaseLocations() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let message = 'Kunne ikke hente lokationer.';
|
|||
|
|
try {
|
|||
|
|
const err = await res.json();
|
|||
|
|
if (err?.detail) {
|
|||
|
|
message = err.detail;
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
}
|
|||
|
|
throw new Error(message);
|
|||
|
|
}
|
|||
|
|
const locations = await res.json();
|
|||
|
|
if (!Array.isArray(locations)) {
|
|||
|
|
throw new Error('Uventet svar fra serveren ved lokations-hentning.');
|
|||
|
|
}
|
|||
|
|
const container = document.getElementById('locations-list');
|
|||
|
|
|
|||
|
|
if (locations.length === 0) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen lokationer tilknyttet</div>';
|
|||
|
|
setModuleContentState('locations', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = `
|
|||
|
|
<div class="location-list-header">
|
|||
|
|
<span>Navn</span>
|
|||
|
|
<span>Type</span>
|
|||
|
|
<span>Slet</span>
|
|||
|
|
</div>
|
|||
|
|
${locations.map(l => `
|
|||
|
|
<div class="location-row">
|
|||
|
|
<div class="fw-semibold">
|
|||
|
|
<i class="bi bi-geo-alt me-1 text-secondary"></i>
|
|||
|
|
${l.name}
|
|||
|
|
</div>
|
|||
|
|
<small>${l.location_type || '-'}</small>
|
|||
|
|
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.relation_id || l.id})" title="Slet">
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
`).join('')}
|
|||
|
|
`;
|
|||
|
|
setModuleContentState('locations', true);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Error loading locations:", e);
|
|||
|
|
const message = (e?.message || '').trim() || 'Fejl ved hentning';
|
|||
|
|
document.getElementById('locations-list').innerHTML = `<div class="p-3 text-danger text-center">${escapeHtml(message)}</div>`;
|
|||
|
|
setModuleContentState('locations', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Wiki Handling ============
|
|||
|
|
async function loadCaseWiki(searchValue = '') {
|
|||
|
|
const container = document.getElementById('wiki-list');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
if (!wikiCustomerId) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen kunde tilknyttet</div>';
|
|||
|
|
setModuleContentState('wiki', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted small">Henter wiki...</div>';
|
|||
|
|
|
|||
|
|
const params = new URLSearchParams();
|
|||
|
|
const trimmed = (searchValue || '').trim();
|
|||
|
|
if (trimmed) {
|
|||
|
|
params.set('query', trimmed);
|
|||
|
|
} else {
|
|||
|
|
params.set('tag', wikiDefaultTag);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/wiki/customers/${wikiCustomerId}/pages?${params.toString()}`);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
throw new Error('Kunne ikke hente Wiki');
|
|||
|
|
}
|
|||
|
|
const payload = await res.json();
|
|||
|
|
if (payload.errors && payload.errors.length) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-danger small">Wiki API fejlede</div>';
|
|||
|
|
setModuleContentState('wiki', true);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pages = Array.isArray(payload.pages) ? payload.pages : [];
|
|||
|
|
|
|||
|
|
if (!pages.length) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen sider fundet</div>';
|
|||
|
|
setModuleContentState('wiki', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = pages.map(page => {
|
|||
|
|
const title = page.title || page.path || 'Wiki side';
|
|||
|
|
const url = page.url || page.path || '#';
|
|||
|
|
const safeUrl = url ? encodeURI(url) : '#';
|
|||
|
|
return `
|
|||
|
|
<a class="list-group-item list-group-item-action" href="${safeUrl}" target="_blank" rel="noopener">
|
|||
|
|
<div class="fw-semibold">${escapeHtml(title)}</div>
|
|||
|
|
<small class="text-muted">${escapeHtml(page.path || '')}</small>
|
|||
|
|
</a>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
setModuleContentState('wiki', true);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Error loading Wiki:', e);
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning</div>';
|
|||
|
|
setModuleContentState('wiki', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadCaseTagsModule() {
|
|||
|
|
const moduleContainer = document.getElementById('case-tags-module');
|
|||
|
|
if (!moduleContainer) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
moduleContainer.innerHTML = tags.map((tag) => `
|
|||
|
|
<span class="badge me-1 mb-1" style="background-color: ${tag.color};">
|
|||
|
|
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)}
|
|||
|
|
<button type="button" class="btn-close btn-close-white btn-sm ms-1"
|
|||
|
|
onclick="removeCaseTagAndSync(${tag.id})"
|
|||
|
|
style="font-size: 0.6rem; vertical-align: middle;"></button>
|
|||
|
|
</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);
|
|||
|
|
moduleContainer.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning af tags</div>';
|
|||
|
|
setModuleContentState('tags', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadCaseTagSuggestions() {
|
|||
|
|
const suggestionsContainer = document.getElementById('case-tag-suggestions');
|
|||
|
|
if (!suggestionsContainer) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`);
|
|||
|
|
if (!response.ok) throw new Error('Kunne ikke hente forslag');
|
|||
|
|
|
|||
|
|
const suggestions = await response.json();
|
|||
|
|
if (!Array.isArray(suggestions) || suggestions.length === 0) {
|
|||
|
|
suggestionsContainer.innerHTML = '<div class="text-muted small">Ingen nye forslag lige nu</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => {
|
|||
|
|
const tag = item.tag || {};
|
|||
|
|
const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : '';
|
|||
|
|
return `
|
|||
|
|
<div class="d-flex justify-content-between align-items-start gap-2 mb-2 border rounded p-2">
|
|||
|
|
<div class="small">
|
|||
|
|
<span class="badge" style="background-color: ${tag.color || '#0f4c75'};">
|
|||
|
|
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name || 'Tag')}
|
|||
|
|
</span>
|
|||
|
|
${matched ? `<div class="text-muted mt-1">Match: ${escapeHtml(matched)}</div>` : ''}
|
|||
|
|
</div>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" type="button" onclick="applySuggestedCaseTag(${Number(tag.id)})">Tilfoej</button>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error loading tag suggestions:', error);
|
|||
|
|
suggestionsContainer.innerHTML = '<div class="text-danger small">Fejl ved forslag</div>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function applySuggestedCaseTag(tagId) {
|
|||
|
|
if (!tagId) return;
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/tags/entity', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const error = await response.json().catch(() => ({}));
|
|||
|
|
throw new Error(error.detail || 'Kunne ikke tilfoeje tag');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await syncCaseTagsUi();
|
|||
|
|
if (typeof showNotification === 'function') {
|
|||
|
|
showNotification('Tag tilfoejet', 'success');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Fejl: ' + error.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function removeCaseTagAndSync(tagId) {
|
|||
|
|
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 && document.getElementById('case-tags')) {
|
|||
|
|
await window.renderEntityTags('case', caseId, 'case-tags');
|
|||
|
|
}
|
|||
|
|
await loadCaseTagsModule();
|
|||
|
|
await loadCaseTagSuggestions();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let todoUserId = null;
|
|||
|
|
|
|||
|
|
function getTodoUserId() {
|
|||
|
|
if (todoUserId) return todoUserId;
|
|||
|
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
|||
|
|
if (token) {
|
|||
|
|
try {
|
|||
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|||
|
|
todoUserId = payload.sub || payload.user_id;
|
|||
|
|
return todoUserId;
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('Could not decode token for todo user_id');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const metaTag = document.querySelector('meta[name="user-id"]');
|
|||
|
|
if (metaTag) {
|
|||
|
|
todoUserId = metaTag.getAttribute('content');
|
|||
|
|
}
|
|||
|
|
return todoUserId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatTodoDate(value) {
|
|||
|
|
if (!value) return '-';
|
|||
|
|
const date = new Date(value);
|
|||
|
|
if (Number.isNaN(date.getTime())) return '-';
|
|||
|
|
return date.toLocaleDateString('da-DK');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatTodoDateTime(value) {
|
|||
|
|
if (!value) return '-';
|
|||
|
|
const date = new Date(value);
|
|||
|
|
if (Number.isNaN(date.getTime())) return '-';
|
|||
|
|
return date.toLocaleString('da-DK', { hour: '2-digit', minute: '2-digit', hour12: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getNextTodoOverrideStorageKey() {
|
|||
|
|
return `case:${caseId}:nextTodoStepId`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getNextTodoOverrideId() {
|
|||
|
|
const raw = localStorage.getItem(getNextTodoOverrideStorageKey());
|
|||
|
|
const parsed = Number(raw);
|
|||
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setNextTodoOverrideId(stepIdOrNull) {
|
|||
|
|
const key = getNextTodoOverrideStorageKey();
|
|||
|
|
if (stepIdOrNull === null || stepIdOrNull === undefined) {
|
|||
|
|
localStorage.removeItem(key);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
localStorage.setItem(key, String(stepIdOrNull));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTodoSteps(steps) {
|
|||
|
|
const list = document.getElementById('todo-steps-list');
|
|||
|
|
if (!list) return;
|
|||
|
|
|
|||
|
|
updateTopbarNextTodo(steps || []);
|
|||
|
|
|
|||
|
|
const escapeAttr = (value) => String(value ?? '')
|
|||
|
|
.replace(/&/g, '&')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
.replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>');
|
|||
|
|
|
|||
|
|
if (!steps || steps.length === 0) {
|
|||
|
|
list.innerHTML = '<div class="p-3 text-center text-muted">Ingen opgaver endnu</div>';
|
|||
|
|
setModuleContentState('todo-steps', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const openSteps = steps.filter(step => !step.is_done);
|
|||
|
|
const doneSteps = steps.filter(step => step.is_done);
|
|||
|
|
const nextOverrideId = getNextTodoOverrideId();
|
|||
|
|
|
|||
|
|
const renderStep = (step) => {
|
|||
|
|
const createdBy = step.created_by_name || 'Ukendt';
|
|||
|
|
const completedBy = step.completed_by_name || 'Ukendt';
|
|||
|
|
const dueLabel = step.due_date ? formatTodoDate(step.due_date) : '-';
|
|||
|
|
const createdLabel = formatTodoDateTime(step.created_at);
|
|||
|
|
const completedLabel = step.completed_at ? formatTodoDateTime(step.completed_at) : null;
|
|||
|
|
const isNextEffective = !step.is_done && (!!step.is_next || (nextOverrideId !== null && step.id === nextOverrideId));
|
|||
|
|
const statusBadge = step.is_done
|
|||
|
|
? '<span class="badge bg-success">Færdig</span>'
|
|||
|
|
: `<span class="badge ${isNextEffective ? 'bg-primary' : 'bg-warning text-dark'}">${isNextEffective ? 'Næste' : 'Åben'}</span>`;
|
|||
|
|
const toggleLabel = step.is_done ? 'Genåbn' : 'Færdig';
|
|||
|
|
const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success';
|
|||
|
|
const nextLabel = isNextEffective ? 'Fjern som næste' : 'Sæt som næste';
|
|||
|
|
const nextClass = isNextEffective ? 'btn-primary' : 'btn-outline-primary';
|
|||
|
|
const tooltipText = [
|
|||
|
|
`Oprettet af: ${createdBy}`,
|
|||
|
|
`Oprettet: ${createdLabel}`,
|
|||
|
|
`Forfald: ${dueLabel}`,
|
|||
|
|
isNextEffective ? 'Markeret som næste opgave' : null,
|
|||
|
|
step.is_done && completedLabel ? `Færdiggjort af: ${completedBy}` : null,
|
|||
|
|
step.is_done && completedLabel ? `Færdiggjort: ${completedLabel}` : null
|
|||
|
|
].filter(Boolean).join('<br>');
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="list-group-item todo-step-item">
|
|||
|
|
<div class="todo-step-title todo-step-header">
|
|||
|
|
<div class="todo-step-left">
|
|||
|
|
<span>${step.title}</span>
|
|||
|
|
<button type="button" class="btn btn-outline-secondary todo-info-btn" data-bs-toggle="tooltip" data-bs-html="true" title="${escapeAttr(tooltipText)}" aria-label="Vis detaljer">
|
|||
|
|
<i class="bi bi-info"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="todo-step-right">
|
|||
|
|
${statusBadge}
|
|||
|
|
<div class="todo-step-actions">
|
|||
|
|
${!step.is_done ? `
|
|||
|
|
<button class="btn btn-sm ${nextClass}" onclick="setNextTodoStep(${step.id}, ${isNextEffective ? 'false' : 'true'})" title="${nextLabel}" aria-label="${nextLabel}">
|
|||
|
|
<i class="bi bi-arrow-up"></i>
|
|||
|
|
</button>
|
|||
|
|
` : ''}
|
|||
|
|
<button class="btn btn-sm ${toggleClass}" onclick="toggleTodoStep(${step.id}, ${step.is_done ? 'false' : 'true'})" title="${toggleLabel}" aria-label="${toggleLabel}">
|
|||
|
|
<i class="bi ${step.is_done ? 'bi-arrow-counterclockwise' : 'bi-check2'}"></i>
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteTodoStep(${step.id})" title="Slet" aria-label="Slet">
|
|||
|
|
<i class="bi bi-trash"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
${step.description ? `<div class="small text-muted">${step.description}</div>` : ''}
|
|||
|
|
<div class="todo-step-meta">
|
|||
|
|
<span class="meta-pill">Forfald: ${dueLabel}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sections = [];
|
|||
|
|
if (openSteps.length) {
|
|||
|
|
sections.push(`
|
|||
|
|
<div class="todo-section-header">Åbne (${openSteps.length})</div>
|
|||
|
|
${openSteps.map(renderStep).join('')}
|
|||
|
|
`);
|
|||
|
|
}
|
|||
|
|
if (doneSteps.length) {
|
|||
|
|
sections.push(`
|
|||
|
|
<div class="todo-section-header">Færdige (${doneSteps.length})</div>
|
|||
|
|
${doneSteps.map(renderStep).join('')}
|
|||
|
|
`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
list.innerHTML = sections.join('');
|
|||
|
|
if (window.bootstrap) {
|
|||
|
|
list.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
|
|||
|
|
bootstrap.Tooltip.getOrCreateInstance(el, {
|
|||
|
|
trigger: 'hover focus',
|
|||
|
|
placement: 'left',
|
|||
|
|
container: 'body',
|
|||
|
|
html: true
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
setModuleContentState('todo-steps', true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateTopbarNextTodo(steps) {
|
|||
|
|
const valueEl = document.getElementById('topbarNextTodoValue');
|
|||
|
|
const metaEl = document.getElementById('topbarNextTodoMeta');
|
|||
|
|
if (!valueEl || !metaEl) return;
|
|||
|
|
|
|||
|
|
const openSteps = Array.isArray(steps) ? steps.filter((step) => !step.is_done) : [];
|
|||
|
|
if (!openSteps.length) {
|
|||
|
|
valueEl.textContent = 'Ingen åbne todo-opgaver';
|
|||
|
|
metaEl.textContent = 'Alt er færdigt';
|
|||
|
|
setNextTodoOverrideId(null);
|
|||
|
|
applyTopbarImportanceBubbles();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const nextOverrideId = getNextTodoOverrideId();
|
|||
|
|
const overrideStep = nextOverrideId ? openSteps.find((step) => step.id === nextOverrideId) : null;
|
|||
|
|
const nextStep = overrideStep || openSteps.find((step) => !!step.is_next) || openSteps[0];
|
|||
|
|
|
|||
|
|
if (!overrideStep && nextOverrideId) {
|
|||
|
|
setNextTodoOverrideId(null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
valueEl.textContent = nextStep.title || 'Untitled todo';
|
|||
|
|
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) {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ is_next: isNext, is_done: false })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const error = await res.json().catch(() => ({}));
|
|||
|
|
throw new Error(error.detail || 'Kunne ikke opdatere næste-opgave');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setNextTodoOverrideId(isNext ? stepId : null);
|
|||
|
|
await loadTodoSteps();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadTodoSteps() {
|
|||
|
|
const list = document.getElementById('todo-steps-list');
|
|||
|
|
if (!list) return;
|
|||
|
|
list.innerHTML = '<div class="p-3 text-center text-muted">Henter opgaver...</div>';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps`);
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke hente steps');
|
|||
|
|
const steps = await res.json();
|
|||
|
|
renderTodoSteps(steps || []);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Error loading todo steps:', e);
|
|||
|
|
list.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning</div>';
|
|||
|
|
setModuleContentState('todo-steps', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toggleTodoStepForm(forceOpen = null) {
|
|||
|
|
const form = document.getElementById('todoStepForm');
|
|||
|
|
const moduleCard = document.querySelector('[data-module="todo-steps"]');
|
|||
|
|
if (!form) return;
|
|||
|
|
|
|||
|
|
const shouldOpen = forceOpen === null ? form.classList.contains('d-none') : Boolean(forceOpen);
|
|||
|
|
|
|||
|
|
if (shouldOpen) {
|
|||
|
|
form.classList.remove('d-none');
|
|||
|
|
if (moduleCard) {
|
|||
|
|
moduleCard.classList.remove('module-empty-compact');
|
|||
|
|
}
|
|||
|
|
const titleInput = document.getElementById('todoStepTitle');
|
|||
|
|
if (titleInput) {
|
|||
|
|
titleInput.focus();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
form.classList.add('d-none');
|
|||
|
|
applyViewLayout(currentCaseView);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createTodoStep(event) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
const titleInput = document.getElementById('todoStepTitle');
|
|||
|
|
const descInput = document.getElementById('todoStepDescription');
|
|||
|
|
const dueInput = document.getElementById('todoStepDueDate');
|
|||
|
|
if (!titleInput) return;
|
|||
|
|
|
|||
|
|
const title = titleInput.value.trim();
|
|||
|
|
if (!title) {
|
|||
|
|
alert('Titel er paakraevet');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const userId = getTodoUserId();
|
|||
|
|
if (!userId) {
|
|||
|
|
alert('Mangler bruger-id. Log ind igen.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps?user_id=${userId}`,
|
|||
|
|
{
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
title,
|
|||
|
|
description: descInput.value.trim() || null,
|
|||
|
|
due_date: dueInput.value || null
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke oprette step');
|
|||
|
|
}
|
|||
|
|
titleInput.value = '';
|
|||
|
|
descInput.value = '';
|
|||
|
|
dueInput.value = '';
|
|||
|
|
await loadTodoSteps();
|
|||
|
|
toggleTodoStepForm(false);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleTodoStep(stepId, isDone) {
|
|||
|
|
const userId = getTodoUserId();
|
|||
|
|
if (!userId) {
|
|||
|
|
alert('Mangler bruger-id. Log ind igen.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}?user_id=${userId}`,
|
|||
|
|
{
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ is_done: isDone })
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke opdatere step');
|
|||
|
|
await loadTodoSteps();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteTodoStep(stepId) {
|
|||
|
|
if (!confirm('Slet dette step?')) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, { method: 'DELETE' });
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke slette step');
|
|||
|
|
await loadTodoSteps();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function promptLinkLocation() {
|
|||
|
|
const id = prompt("Indtast Lokations ID:");
|
|||
|
|
if (!id) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({ location_id: parseInt(id) })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) throw await res.json();
|
|||
|
|
loadCaseLocations();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert("Fejl: " + (e.detail || e.message));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function unlinkLocation(locId) {
|
|||
|
|
if(!confirm("Fjern link til denne lokation?")) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json().catch(() => ({}));
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke fjerne lokation');
|
|||
|
|
}
|
|||
|
|
loadCaseLocations();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Initialize relation search when DOM is ready
|
|||
|
|
if (document.readyState === 'loading') {
|
|||
|
|
document.addEventListener('DOMContentLoaded', setupRelationSearch);
|
|||
|
|
} else {
|
|||
|
|
setupRelationSearch();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Kontakt Modal functions
|
|||
|
|
function showKontaktModal() {
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('kontaktModal'));
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Afdeling Modal functions
|
|||
|
|
function showAfdelingModal() {
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('afdelingModal'));
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function updateAfdeling() {
|
|||
|
|
const newAfdeling = document.getElementById('afdelingInput').value.trim();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/customers/{{ customer.id if customer else 0 }}', {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({ department: newAfdeling })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) throw await response.json();
|
|||
|
|
|
|||
|
|
// Reload page to show updated data
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert("Fejl ved opdatering: " + (e.detail || e.message));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<!-- Tid & Fakturering Section (Moved from Right Column) -->
|
|||
|
|
<div class="card mt-3 right-module-card module-priority-high" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<div class="d-flex align-items-center">
|
|||
|
|
<h5 class="module-title"><i class="bi bi-clock-history module-icon"></i>Tid & Fakturering</h5>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
|
|||
|
|
<i class="bi bi-fullscreen me-1"></i>Fuld Formular
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<!-- Quick Add Time Entry Form -->
|
|||
|
|
<div class="border rounded p-2 mb-2 bg-light" id="quickTimeFormContainer">
|
|||
|
|
<form id="quickAddTimeForm" onsubmit="quickAddTime(event); return false;">
|
|||
|
|
<div class="row g-1 align-items-end">
|
|||
|
|
<div class="col-md-2 col-6">
|
|||
|
|
<label for="quickTimeDate" class="form-label small mb-1">Dato</label>
|
|||
|
|
<input type="date" class="form-control form-control-sm" id="quickTimeDate" name="date"
|
|||
|
|
value="{{ today or '' }}" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-1 col-3">
|
|||
|
|
<label for="quickTimeHours" class="form-label small mb-1">Timer</label>
|
|||
|
|
<input type="number" class="form-control form-control-sm" id="quickTimeHours" name="hours"
|
|||
|
|
min="0" max="23" value="0" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-1 col-3">
|
|||
|
|
<label for="quickTimeMinutes" class="form-label small mb-1">Min</label>
|
|||
|
|
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
|
|||
|
|
min="0" max="59" step="15" value="0" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3 col-6">
|
|||
|
|
<label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
|
|||
|
|
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
|
|||
|
|
<option value="invoice" selected>Faktura</option>
|
|||
|
|
{% if prepaid_cards %}
|
|||
|
|
<optgroup label="Klippekort">
|
|||
|
|
{% for card in prepaid_cards %}
|
|||
|
|
<option value="card_{{ card.id }}">💳 Kort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t)</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</optgroup>
|
|||
|
|
{% endif %}
|
|||
|
|
{% if fixed_price_agreements %}
|
|||
|
|
<optgroup label="Fastpris">
|
|||
|
|
{% for agr in fixed_price_agreements %}
|
|||
|
|
<option value="fpa_{{ agr.id }}">📋 #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t)</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</optgroup>
|
|||
|
|
{% endif %}
|
|||
|
|
<option value="internal">Internt</option>
|
|||
|
|
<option value="warranty">Garanti</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4 col-12">
|
|||
|
|
<label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
|
|||
|
|
placeholder="Hvad har du lavet?" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-1 col-12 d-flex align-items-end">
|
|||
|
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
|||
|
|
<i class="bi bi-plus-lg me-0"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Time Entries Table -->
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-hover mb-0">
|
|||
|
|
<thead class="table-light">
|
|||
|
|
<tr>
|
|||
|
|
<th>Dato</th>
|
|||
|
|
<th>Beskrivelse</th>
|
|||
|
|
<th>Bruger</th>
|
|||
|
|
<th class="text-end">Timer</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{% for entry in time_entries %}
|
|||
|
|
<tr>
|
|||
|
|
<td>{{ entry.worked_date }}</td>
|
|||
|
|
<td>{{ entry.description or '-' }}</td>
|
|||
|
|
<td>{{ entry.user_name }}</td>
|
|||
|
|
<td class="text-end fw-bold">{{ entry.original_hours }}</td>
|
|||
|
|
</tr>
|
|||
|
|
{% else %}
|
|||
|
|
<tr>
|
|||
|
|
<td colspan="4" class="text-center py-3 text-muted">
|
|||
|
|
<i class="bi bi-inbox me-2"></i>Ingen tid registreret endnu
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
{% endfor %}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Prepaid Cards Info -->
|
|||
|
|
{% if prepaid_cards %}
|
|||
|
|
<div class="border-top mt-3 pt-3">
|
|||
|
|
<h6 class="mb-2"><i class="bi bi-credit-card me-1"></i>Aktive Klippekort</h6>
|
|||
|
|
<div class="row g-2">
|
|||
|
|
{% for card in prepaid_cards %}
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<div class="border rounded p-2 bg-light">
|
|||
|
|
<div class="small text-muted">Kort #{{ card.card_number or card.id }}</div>
|
|||
|
|
<div class="fw-bold text-primary">{{ '%.2f' % card.remaining_hours }} timer tilbage</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{% endfor %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</div></div><!-- slut inner cols -->
|
|||
|
|
<div class="col-xl-4 col-lg-4" id="case-right-column">
|
|||
|
|
<div class="right-modules-grid">
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="locations" data-has-content="unknown">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-geo-alt-fill module-icon"></i>Lokationer</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('location')">
|
|||
|
|
<i class="bi bi-plus-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body flex-grow-1 overflow-auto p-0" style="max-height: 180px;">
|
|||
|
|
<div class="list-group list-group-flush" id="locations-list">
|
|||
|
|
<div class="p-3 text-center text-muted">Henter lokationer...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" data-has-content="unknown">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary"
|
|||
|
|
onclick="window.showTagPicker('case', {{ case.id }}, () => syncCaseTagsUi())"
|
|||
|
|
title="Tilføj tag">
|
|||
|
|
<i class="bi bi-plus-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" style="max-height: 240px; overflow: auto;">
|
|||
|
|
<div id="case-tags-module" class="mb-2">
|
|||
|
|
<div class="p-2 text-muted small">Indlaeser tags...</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="border-top pt-2">
|
|||
|
|
<div class="small text-muted fw-semibold mb-2">Forslag (brand/type)</div>
|
|||
|
|
<div id="case-tag-suggestions">
|
|||
|
|
<div class="text-muted small">Indlaeser forslag...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="customers" data-has-content="{{ 'true' if customers else 'false' }}">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-building-fill module-icon"></i>Kunder</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('customer')">
|
|||
|
|
<i class="bi bi-plus-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body flex-grow-1 overflow-auto" id="customers-container" style="max-height: 180px;">
|
|||
|
|
{% if customers %}
|
|||
|
|
<div class="customer-list-header">
|
|||
|
|
<span>Navn</span>
|
|||
|
|
<span>Rolle</span>
|
|||
|
|
<span>E-mail</span>
|
|||
|
|
<span>Slet</span>
|
|||
|
|
</div>
|
|||
|
|
{% for customer in customers %}
|
|||
|
|
<div class="customer-row">
|
|||
|
|
<div>
|
|||
|
|
<a href="/customers/{{ customer.customer_id }}" class="text-decoration-none fw-semibold">
|
|||
|
|
{{ customer.customer_name }}
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
<small>{{ customer.role or '-' }}</small>
|
|||
|
|
<small>{{ customer.customer_email or '-' }}</small>
|
|||
|
|
<button onclick="removeCustomer({{ case.id }}, {{ customer.customer_id }})" class="btn btn-sm btn-delete" title="Slet">✕</button>
|
|||
|
|
</div>
|
|||
|
|
{% endfor %}
|
|||
|
|
{% else %}
|
|||
|
|
<p class="text-muted text-center">Ingen kunder</p>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="contacts" data-has-content="{{ 'true' if contacts else 'false' }}">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-people-fill module-icon"></i>Kontakter</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('contact')">
|
|||
|
|
<i class="bi bi-plus-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body flex-grow-1 overflow-auto" id="contacts-container" style="max-height: 180px;">
|
|||
|
|
{% if contacts %}
|
|||
|
|
<div class="contact-list-header">
|
|||
|
|
<span>Navn</span>
|
|||
|
|
<span>Titel</span>
|
|||
|
|
<span>Kunde</span>
|
|||
|
|
<span>Slet</span>
|
|||
|
|
</div>
|
|||
|
|
{% for contact in contacts %}
|
|||
|
|
<div
|
|||
|
|
class="contact-row"
|
|||
|
|
role="button"
|
|||
|
|
tabindex="0"
|
|||
|
|
onclick="showContactInfoModal(this)"
|
|||
|
|
data-contact-id="{{ contact.contact_id }}"
|
|||
|
|
data-name="{{ contact.contact_name|replace('"', '"') }}"
|
|||
|
|
data-title="{{ contact.title|default('', true)|replace('"', '"') }}"
|
|||
|
|
data-company="{{ contact.customer_name|default('', true)|replace('"', '"') }}"
|
|||
|
|
data-email="{{ contact.contact_email|default('', true)|replace('"', '"') }}"
|
|||
|
|
data-phone="{{ contact.phone|default('', true)|replace('"', '"') }}"
|
|||
|
|
data-mobile="{{ contact.mobile|default('', true)|replace('"', '"') }}"
|
|||
|
|
data-role="{{ contact.role|default('Kontakt')|replace('"', '"') }}"
|
|||
|
|
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
|
|||
|
|
>
|
|||
|
|
<div class="contact-name">{{ contact.contact_name }}</div>
|
|||
|
|
<small>{{ contact.title or '-' }}</small>
|
|||
|
|
<small>{{ contact.customer_name or '-' }}</small>
|
|||
|
|
<button
|
|||
|
|
class="btn btn-sm btn-delete"
|
|||
|
|
onclick="event.stopPropagation(); removeContact({{ case.id }}, {{ contact.contact_id }})"
|
|||
|
|
title="Slet"
|
|||
|
|
>
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{% endfor %}
|
|||
|
|
{% else %}
|
|||
|
|
<p class="text-muted text-center">Ingen kontakter</p>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card d-flex flex-column h-100 right-module-card module-priority-normal" data-module="hardware" data-has-content="unknown">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-pc-display-horizontal module-icon"></i>Hardware</h6>
|
|||
|
|
<div class="d-flex gap-2 align-items-center">
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="sendCaseHardwareLabelsToPrinter()" title="Print labels for alt hardware på sagen">
|
|||
|
|
<i class="bi bi-printer"></i>
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('hardware')" title="Tilknyt hardware">
|
|||
|
|
<i class="bi bi-link-45deg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body flex-grow-1 overflow-auto p-0" style="max-height: 180px;">
|
|||
|
|
<div class="list-group list-group-flush" id="hardware-list">
|
|||
|
|
<div class="p-3 text-center text-muted">Henter hardware...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-high" data-module="pipeline" data-has-content="{{ 'true' if case.pipeline_stage_id or case.pipeline_amount or case.pipeline_probability else 'false' }}">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-graph-up-arrow module-icon"></i>Salgspipeline</h6>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button id="pipelineCollapseToggle" class="btn btn-sm btn-outline-secondary" onclick="togglePipelineModule()" title="Vis/skjul pipeline">
|
|||
|
|
<i id="pipelineCollapseIcon" class="bi bi-chevron-down"></i>
|
|||
|
|
</button>
|
|||
|
|
<button id="pipelineEditToggle" class="btn btn-sm btn-outline-primary" onclick="togglePipelineEdit()">
|
|||
|
|
<i class="bi bi-pencil"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div id="pipelineCardBody" class="card-body d-none">
|
|||
|
|
<div id="pipelineViewMode">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-12">
|
|||
|
|
<div class="summary-item h-100">
|
|||
|
|
<div class="summary-label">Stage</div>
|
|||
|
|
<div class="summary-value">
|
|||
|
|
{% set ns = namespace(selected_stage=None) %}
|
|||
|
|
{% for stage in pipeline_stages or [] %}
|
|||
|
|
{% if case.pipeline_stage_id == stage.id %}
|
|||
|
|
{% set ns.selected_stage = stage %}
|
|||
|
|
{% endif %}
|
|||
|
|
{% endfor %}
|
|||
|
|
{% if ns.selected_stage %}
|
|||
|
|
<span class="badge" style="background: {{ ns.selected_stage.color or '#0f4c75' }};">{{ ns.selected_stage.name }}</span>
|
|||
|
|
{% else %}
|
|||
|
|
<span class="text-muted">Ikke sat</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<div class="summary-item h-100">
|
|||
|
|
<div class="summary-label">Sandsynlighed</div>
|
|||
|
|
<div class="summary-value">{{ case.pipeline_probability if case.pipeline_probability is not none else 0 }}%</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<div class="summary-item h-100">
|
|||
|
|
<div class="summary-label">Beløb</div>
|
|||
|
|
<div class="summary-value">
|
|||
|
|
{% if case.pipeline_amount is not none %}
|
|||
|
|
{{ "{:,.2f}".format(case.pipeline_amount|float).replace(',', 'X').replace('.', ',').replace('X', '.') }} kr.
|
|||
|
|
{% else %}
|
|||
|
|
<span class="text-muted">Ikke sat</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mt-3">
|
|||
|
|
<div class="summary-item">
|
|||
|
|
<div class="summary-label">Beskrivelse</div>
|
|||
|
|
<div class="summary-value" style="white-space: pre-wrap;">{{ case.pipeline_description or 'Ingen beskrivelse' }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="pipelineEditMode" class="d-none">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label small text-muted">Stage</label>
|
|||
|
|
<select id="pipelineStageSelect" class="form-select form-select-sm">
|
|||
|
|
<option value="">Ikke sat</option>
|
|||
|
|
{% for stage in pipeline_stages or [] %}
|
|||
|
|
<option value="{{ stage.id }}" {% if case.pipeline_stage_id == stage.id %}selected{% endif %}>{{ stage.name }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label small text-muted">Sandsynlighed (%)</label>
|
|||
|
|
<input id="pipelineProbabilityInput" type="number" min="0" max="100" class="form-control form-control-sm" value="{{ case.pipeline_probability if case.pipeline_probability is not none else '' }}">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label small text-muted">Beløb (kr.)</label>
|
|||
|
|
<input id="pipelineAmountInput" type="number" min="0" step="0.01" class="form-control form-control-sm" value="{{ case.pipeline_amount if case.pipeline_amount is not none else '' }}">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label small text-muted">Beskrivelse</label>
|
|||
|
|
<textarea id="pipelineDescriptionInput" rows="3" class="form-control form-control-sm" placeholder="Skriv kort note for denne pipeline-entry...">{{ case.pipeline_description or '' }}</textarea>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex justify-content-end gap-2 mt-3">
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="togglePipelineEdit(false)">Annuller</button>
|
|||
|
|
<button class="btn btn-sm btn-primary" onclick="savePipeline()">Gem pipeline</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-critical" data-module="call-history" data-has-content="{{ 'true' if call_history else 'false' }}">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-telephone-inbound-fill module-icon"></i>Opkaldshistorik</h6>
|
|||
|
|
<a href="/telefoni" class="btn btn-sm btn-outline-primary">
|
|||
|
|
<i class="bi bi-telephone"></i>
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0">
|
|||
|
|
{% if call_history and call_history|length > 0 %}
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-sm table-hover mb-0 align-middle">
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th class="ps-3">Dato</th>
|
|||
|
|
<th>Retning</th>
|
|||
|
|
<th>Nummer</th>
|
|||
|
|
<th>Bruger</th>
|
|||
|
|
<th class="text-end pe-3">Varighed</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{% for call in call_history %}
|
|||
|
|
<tr>
|
|||
|
|
<td class="ps-3">{{ call.started_at.strftime('%d/%m/%Y %H:%M') if call.started_at else '-' }}</td>
|
|||
|
|
<td>{{ 'Udgående' if call.direction == 'outbound' else 'Indgående' }}</td>
|
|||
|
|
<td>
|
|||
|
|
{% if call.ekstern_nummer %}
|
|||
|
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
|||
|
|
<span>{{ call.ekstern_nummer }}</span>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-success" onclick='ringOutFromCase({{ (call.ekstern_nummer or "")|tojson }})'>
|
|||
|
|
Ring op
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{% else %}
|
|||
|
|
-
|
|||
|
|
{% endif %}
|
|||
|
|
</td>
|
|||
|
|
<td>{{ call.full_name or call.username or '-' }}</td>
|
|||
|
|
<td class="text-end pe-3">
|
|||
|
|
{% if call.duration_sec is not none %}
|
|||
|
|
{{ (call.duration_sec // 60)|int }}:{{ '%02d'|format((call.duration_sec % 60)|int) }}
|
|||
|
|
{% elif call.ended_at %}
|
|||
|
|
-
|
|||
|
|
{% else %}
|
|||
|
|
I gang
|
|||
|
|
{% endif %}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
{% endfor %}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
{% else %}
|
|||
|
|
<div class="p-3 text-muted text-center">Ingen opkald linket til denne sag</div>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-high" data-module="todo-steps" data-has-content="unknown">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-check2-square module-icon"></i>Todo-opgaver</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleTodoStepForm()" title="Tilføj opgave">
|
|||
|
|
<i class="bi bi-plus-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0 d-flex flex-column" style="max-height: 260px;">
|
|||
|
|
<form id="todoStepForm" class="p-3 border-bottom d-none">
|
|||
|
|
<input type="text" class="form-control form-control-sm mb-2" id="todoStepTitle" placeholder="Opgavetitel" required>
|
|||
|
|
<textarea class="form-control form-control-sm mb-2" id="todoStepDescription" rows="2" placeholder="Kort note (valgfri)"></textarea>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<input type="date" class="form-control form-control-sm" id="todoStepDueDate">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" type="submit">Tilføj</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="todo-steps-list">
|
|||
|
|
<div class="p-3 text-center text-muted">Ingen opgaver endnu</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="wiki" data-has-content="unknown">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="module-title"><i class="bi bi-journal-richtext module-icon"></i>Kunde-wiki</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body flex-grow-1 p-0" style="max-height: 220px; overflow: auto;">
|
|||
|
|
<div class="p-2 border-bottom">
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="wikiSearchInput" placeholder="Soeg i Wiki (tom = guide)" style="font-size: 0.8rem;">
|
|||
|
|
</div>
|
|||
|
|
<div class="list-group list-group-flush" id="wiki-list">
|
|||
|
|
<div class="p-3 text-center text-muted">Henter wiki...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div> <!-- End Details Tab -->
|
|||
|
|
|
|||
|
|
<!-- E-mail Tab -->
|
|||
|
|
<div class="tab-pane fade" id="emails" role="tabpanel" tabindex="0" data-module="emails" data-has-content="unknown" style="display:none;">
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-envelope me-2"></i>E-mail på sagen</h6>
|
|||
|
|
<div class="d-flex gap-2 align-items-center">
|
|||
|
|
<button class="btn btn-sm btn-primary" type="button" onclick="startNewEmailThread()">
|
|||
|
|
<i class="bi bi-envelope-plus me-1"></i>Ny email
|
|||
|
|
</button>
|
|||
|
|
<input type="file" id="emailImportInput" accept=".eml,.msg" style="display:none" onchange="if(this.files?.length){ uploadEmailFile(this.files[0]); this.value=''; }">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" type="button" onclick="document.getElementById('emailImportInput').click()">
|
|||
|
|
<i class="bi bi-cloud-upload me-1"></i>Importér .eml/.msg
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" id="emailDropZone">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-12">
|
|||
|
|
<div class="row g-2">
|
|||
|
|
<div class="col-lg-4 position-relative">
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="emailSearchInput" placeholder="Søg og link e-mail..." autocomplete="off">
|
|||
|
|
<div class="list-group position-absolute shadow-sm" id="emailSearchResults" style="z-index: 1000; display: none; top: 100%; left: 0; right: 0; max-height: 300px; overflow-y: auto;"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-4">
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="emailFilterInput" placeholder="Filtrer i linkede e-mails...">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-2">
|
|||
|
|
<select id="emailAttachmentFilter" class="form-select form-select-sm">
|
|||
|
|
<option value="all">Alle vedhæftninger</option>
|
|||
|
|
<option value="with">Med vedhæftning</option>
|
|||
|
|
<option value="without">Uden vedhæftning</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-2">
|
|||
|
|
<select id="emailReadFilter" class="form-select form-select-sm">
|
|||
|
|
<option value="all">Alle læsestatus</option>
|
|||
|
|
<option value="unread">Ulæste</option>
|
|||
|
|
<option value="read">Læste</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="small text-muted fst-italic mt-2">Tip: Træk .msg/.eml fil hertil for at importere direkte på sagen.</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="col-lg-3 col-xl-2">
|
|||
|
|
<div class="email-column-shell h-100 d-flex flex-column">
|
|||
|
|
<div class="column-header p-2 border-bottom d-flex justify-content-between align-items-center">
|
|||
|
|
<span class="small fw-semibold text-secondary">Mail tråd</span>
|
|||
|
|
<span class="badge bg-light text-dark border" id="linkedEmailThreadsCount">0</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="email-threads-list" style="min-height: 420px; max-height: 65vh;">
|
|||
|
|
<div class="p-3 text-center text-muted">Ingen tråde fundet...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="col-lg-3 col-xl-3">
|
|||
|
|
<div class="email-column-shell h-100 d-flex flex-column">
|
|||
|
|
<div class="column-header p-2 border-bottom d-flex justify-content-between align-items-center">
|
|||
|
|
<span class="small fw-semibold text-secondary">Mails i tråden</span>
|
|||
|
|
<span class="badge bg-light text-dark border" id="threadEmailsCount">0</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="linked-emails-list" style="min-height: 420px; max-height: 65vh;">
|
|||
|
|
<div class="p-3 text-center text-muted">Ingen e-mails linket...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="col-lg-6 col-xl-7">
|
|||
|
|
<div class="email-column-shell h-100 d-flex flex-column" id="email-preview-panel" style="min-height: 420px;">
|
|||
|
|
<div class="p-3 text-center text-muted d-flex align-items-center justify-content-center flex-grow-1">
|
|||
|
|
Vælg en e-mail i listen for at se indhold og vedhæftninger
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="modal fade" id="caseEmailComposeModal" tabindex="-1" aria-labelledby="caseEmailComposeModalLabel" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title" id="caseEmailComposeModalLabel"><i class="bi bi-envelope me-2"></i>Ny email</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div class="d-flex flex-column gap-2">
|
|||
|
|
<div class="row g-2">
|
|||
|
|
<div class="col-lg-6">
|
|||
|
|
<label for="caseEmailTo" class="form-label form-label-sm mb-1">Til</label>
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="caseEmailTo" placeholder="modtager@eksempel.dk">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-3">
|
|||
|
|
<label for="caseEmailCc" class="form-label form-label-sm mb-1">Cc</label>
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="caseEmailCc" placeholder="cc@eksempel.dk">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-3">
|
|||
|
|
<label for="caseEmailBcc" class="form-label form-label-sm mb-1">Bcc</label>
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="caseEmailBcc" placeholder="bcc@eksempel.dk">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="row g-2">
|
|||
|
|
<div class="col-lg-8">
|
|||
|
|
<label for="caseEmailSubject" class="form-label form-label-sm mb-1">Emne</label>
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="caseEmailSubject" placeholder="Emne">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-4">
|
|||
|
|
<label for="caseEmailAttachmentIds" class="form-label form-label-sm mb-1">Vedhæft sagsfiler</label>
|
|||
|
|
<select id="caseEmailAttachmentIds" class="form-select form-select-sm" multiple>
|
|||
|
|
<option disabled>Ingen sagsfiler tilgængelige</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|||
|
|
<label for="caseEmailBody" class="form-label form-label-sm mb-0">Besked</label>
|
|||
|
|
<button id="caseEmailRewriteBtn" type="button" class="btn btn-outline-primary btn-sm">
|
|||
|
|
<i class="bi bi-magic me-1"></i>Renskriv med AI
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<textarea class="form-control form-control-sm" id="caseEmailBody" rows="10" placeholder="Skriv besked..."></textarea>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer d-flex justify-content-between">
|
|||
|
|
<small id="caseEmailSendStatus" class="text-muted"></small>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" id="caseEmailSendBtn" class="btn btn-primary btn-sm">Send email</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="modal fade" id="rewritePreviewModal" tabindex="-1" aria-labelledby="rewritePreviewModalLabel" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title" id="rewritePreviewModalLabel"><i class="bi bi-pencil-square me-2"></i>AI forslag til renskrivning</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div id="rewritePreviewSummary" class="small text-muted mb-3"></div>
|
|||
|
|
<div id="rewritePreviewNoChanges" class="alert alert-info d-none mb-3">AI fandt ingen ændringer.</div>
|
|||
|
|
<div id="rewritePreviewList" class="d-flex flex-column gap-2"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer d-flex justify-content-between">
|
|||
|
|
<small id="rewritePreviewSelectionInfo" class="text-muted">Ingen ændringer valgt</small>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" id="rewriteApplySelectedBtn" class="btn btn-outline-primary btn-sm">Godkend valgte</button>
|
|||
|
|
<button type="button" id="rewriteApplyAllBtn" class="btn btn-primary btn-sm">Godkend alle</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Solution Tab -->
|
|||
|
|
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}" style="display:none;">
|
|||
|
|
<!-- Nextcloud Integration Box -->
|
|||
|
|
{% if is_nextcloud %}
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0" style="color: var(--accent);">☁️ Nextcloud Integration</h6>
|
|||
|
|
{% if nextcloud_instance %}
|
|||
|
|
<span class="badge bg-success" style="background: var(--accent) !important;">Aktiv</span>
|
|||
|
|
{% else %}
|
|||
|
|
<span class="badge bg-warning text-dark">Ingen instans</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
{% if nextcloud_instance %}
|
|||
|
|
<!-- Info Row -->
|
|||
|
|
<div class="d-flex flex-wrap gap-4 mb-3 border-bottom pb-3">
|
|||
|
|
<div>
|
|||
|
|
<span class="text-muted small d-block">Instans</span>
|
|||
|
|
<a href="{{ nextcloud_instance.base_url }}" target="_blank" style="color: var(--accent); text-decoration: none; font-weight: 500;">
|
|||
|
|
{{ nextcloud_instance.base_url }} <i class="bi bi-box-arrow-up-right small"></i>
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<span class="text-muted small d-block">Admin Konto</span>
|
|||
|
|
<span class="font-monospace text-dark">{{ nextcloud_instance.username }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Actions -->
|
|||
|
|
<div>
|
|||
|
|
<span class="text-muted small d-block mb-2">Handlinger</span>
|
|||
|
|
<div class="d-flex flex-wrap gap-2">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#ncCreateUserModal">
|
|||
|
|
<i class="bi bi-person-plus me-1"></i> Opret bruger
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#ncDisableUserModal">
|
|||
|
|
<i class="bi bi-person-lock me-1"></i> Luk bruger
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#ncResetPasswordModal">
|
|||
|
|
<i class="bi bi-key me-1"></i> Reset kode
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-info" data-bs-toggle="modal" data-bs-target="#ncSendGuideModal">
|
|||
|
|
<i class="bi bi-envelope me-1"></i> Send guide
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{% else %}
|
|||
|
|
<div class="text-center py-2 text-muted">
|
|||
|
|
<i class="bi bi-exclamation-triangle me-2 text-warning"></i>
|
|||
|
|
Kunden mangler Nextcloud konfiguration
|
|||
|
|
</div>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{% endif %}
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-lightbulb me-2"></i>Løsning</h6>
|
|||
|
|
{% if not solution or request.query_params.get('edit_solution') %}
|
|||
|
|
<!-- button to create/edit -->
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
{% if solution %}
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="small text-muted">Titel</label>
|
|||
|
|
<div class="fw-bold">{{ solution.title }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="row mb-3">
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="small text-muted">Type</label>
|
|||
|
|
<div><span class="badge bg-secondary">{{ solution.solution_type }}</span></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="small text-muted">Resultat</label>
|
|||
|
|
<div><span class="badge {{ 'bg-success' if solution.result == 'Løst' else 'bg-warning' }}">{{ solution.result }}</span></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label class="small text-muted">Beskrivelse</label>
|
|||
|
|
<div class="p-3 bg-light rounded" style="white-space: pre-wrap;">{{ solution.description }}</div>
|
|||
|
|
</div>
|
|||
|
|
{% else %}
|
|||
|
|
<div class="text-center py-5 text-muted">
|
|||
|
|
<i class="bi bi-lightbulb display-4 mb-3 d-block opacity-25"></i>
|
|||
|
|
<p>Ingen løsning registreret endnu.</p>
|
|||
|
|
<button class="btn btn-primary" onclick="showCreateSolutionModal()">
|
|||
|
|
<i class="bi bi-plus-lg me-2"></i>Opret Løsning
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Varekøb & Salg Tab -->
|
|||
|
|
<div class="tab-pane fade" id="sales" role="tabpanel" tabindex="0" data-module="sales" data-has-content="unknown" style="display:none;">
|
|||
|
|
<div class="row g-3 mb-3">
|
|||
|
|
<div class="col-lg-8">
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-bag-check me-2"></i>Salgslinjer</h6>
|
|||
|
|
<span class="badge bg-light text-dark border" id="salesLinesSubtotal">-</span>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openSaleItemModal({ type: 'sale' })">
|
|||
|
|
<i class="bi bi-plus-lg me-1"></i>Tilføj salgslinje
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0">
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
|||
|
|
<thead class="bg-light">
|
|||
|
|
<tr>
|
|||
|
|
<th class="ps-4">Dato</th>
|
|||
|
|
<th>Beskrivelse</th>
|
|||
|
|
<th>Antal</th>
|
|||
|
|
<th>Enhed</th>
|
|||
|
|
<th>Enhedspris</th>
|
|||
|
|
<th>Linjesum</th>
|
|||
|
|
<th>Kilde-sag</th>
|
|||
|
|
<th>Status</th>
|
|||
|
|
<th class="text-end pe-4">Handlinger</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody id="saleItemsSalesBody">
|
|||
|
|
<tr>
|
|||
|
|
<td colspan="9" class="text-center py-4 text-muted">Indlæser salgslinjer...</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-cart-x me-2"></i>Indkøbslinjer</h6>
|
|||
|
|
<span class="badge bg-light text-dark border" id="purchaseLinesSubtotal">-</span>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openSaleItemModal({ type: 'purchase' })">
|
|||
|
|
<i class="bi bi-plus-lg me-1"></i>Tilføj indkøbslinje
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0">
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
|||
|
|
<thead class="bg-light">
|
|||
|
|
<tr>
|
|||
|
|
<th class="ps-4">Dato</th>
|
|||
|
|
<th>Beskrivelse</th>
|
|||
|
|
<th>Antal</th>
|
|||
|
|
<th>Enhed</th>
|
|||
|
|
<th>Enhedspris</th>
|
|||
|
|
<th>Linjesum</th>
|
|||
|
|
<th>Formål</th>
|
|||
|
|
<th>Kilde-sag</th>
|
|||
|
|
<th>Status</th>
|
|||
|
|
<th class="text-end pe-4">Handlinger</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody id="saleItemsPurchaseBody">
|
|||
|
|
<tr>
|
|||
|
|
<td colspan="10" class="text-center py-4 text-muted">Indlæser indkøbslinjer...</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-4">
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-graph-up-arrow me-2"></i>Salg (samlet)</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|||
|
|
<span class="text-muted">Total salg</span>
|
|||
|
|
<strong id="salesTotalSale">-</strong>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex justify-content-between align-items-center">
|
|||
|
|
<span class="text-muted">Netto</span>
|
|||
|
|
<strong id="salesTotalNet">-</strong>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-bag-check me-2"></i>Indkøb (samlet)</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="d-flex justify-content-between align-items-center">
|
|||
|
|
<span class="text-muted">Total køb</span>
|
|||
|
|
<strong id="salesTotalPurchase">-</strong>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid (samlet)</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|||
|
|
<span class="text-muted">Timer (total)</span>
|
|||
|
|
<strong id="salesTotalHours">-</strong>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex justify-content-between align-items-center">
|
|||
|
|
<span class="text-muted">Timer (fakturerbar)</span>
|
|||
|
|
<strong id="salesBillableHours">-</strong>
|
|||
|
|
</div>
|
|||
|
|
<div class="small text-muted mt-3">
|
|||
|
|
Inkluderer alle under-sager
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid (samlet)</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0">
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
|||
|
|
<thead class="bg-light">
|
|||
|
|
<tr>
|
|||
|
|
<th class="ps-3">Dato</th>
|
|||
|
|
<th>Timer</th>
|
|||
|
|
<th>Kilde-sag</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody id="salesTimeBody">
|
|||
|
|
<tr>
|
|||
|
|
<td colspan="3" class="text-center py-4 text-muted">Indlæser tid...</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Leverandør Tab -->
|
|||
|
|
<div class="tab-pane fade" id="supplier" role="tabpanel" tabindex="0" data-module="supplier" data-has-content="unknown" style="display:none;">
|
|||
|
|
<div class="row g-3 mb-3">
|
|||
|
|
<div class="col-xl-8 col-12">
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-diagram-3 me-2"></i>Leverandørflow Type</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierFlowType()">
|
|||
|
|
<i class="bi bi-save me-1"></i>Gem type
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="row g-2 align-items-end">
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Type</label>
|
|||
|
|
<select class="form-select" id="supplierFlowTypeSelect" onchange="toggleSupplierTypePanels()">
|
|||
|
|
<option value="">Vælg type...</option>
|
|||
|
|
<option value="varekob" {% if case.supplier_flow_type == 'varekob' %}selected{% endif %}>Varekøb</option>
|
|||
|
|
<option value="ydelse" {% if case.supplier_flow_type == 'ydelse' %}selected{% endif %}>Købt ydelse</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">AI confidence</label>
|
|||
|
|
<input type="number" class="form-control" id="supplierFlowConfidenceInput" min="0" max="1" step="0.01" value="{{ case.supplier_flow_confidence if case.supplier_flow_confidence is not none else '' }}" placeholder="0.00 - 1.00">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Forslag</label>
|
|||
|
|
<div id="supplierFlowSuggestion" class="small text-muted border rounded p-2">Ingen forslag endnu</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card mb-3" id="supplierVarekobPanel" style="display:none;">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-box-seam me-2"></i>VAREKØB handlinger pr. linje</h6>
|
|||
|
|
<span class="badge bg-light text-dark border" id="supplierPurchaseLinesCount">0 linjer</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0">
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
|||
|
|
<thead class="bg-light">
|
|||
|
|
<tr>
|
|||
|
|
<th class="ps-3">Beskrivelse</th>
|
|||
|
|
<th>Beløb</th>
|
|||
|
|
<th>Handling</th>
|
|||
|
|
<th class="text-end pe-3">Gem</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody id="supplierPurchaseLinesBody">
|
|||
|
|
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card mb-3" id="supplierYdelsePanel" style="display:none;">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-tools me-2"></i>YDELSE handling</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="row g-2">
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Ydelsestype</label>
|
|||
|
|
<select class="form-select" id="supplierServiceType">
|
|||
|
|
<option value="husleje">Husleje</option>
|
|||
|
|
<option value="rengoering">Rengøring</option>
|
|||
|
|
<option value="konsulent">Konsulent</option>
|
|||
|
|
<option value="abonnement">Abonnement</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Periode fra</label>
|
|||
|
|
<input type="date" class="form-control" id="supplierServiceFrom">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Periode til</label>
|
|||
|
|
<input type="date" class="form-control" id="supplierServiceTo">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-2">
|
|||
|
|
<label class="form-label">Beløb</label>
|
|||
|
|
<input type="number" min="0" step="0.01" class="form-control" id="supplierServiceAmount" placeholder="0,00">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex gap-2 mt-3 flex-wrap">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('direkte_omkostning')">
|
|||
|
|
<i class="bi bi-cash-stack me-1"></i>Direkte omkostning
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('fordel_kunder')">
|
|||
|
|
<i class="bi bi-diagram-2 me-1"></i>Fordel på kunder
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('gentagende_ydelse')">
|
|||
|
|
<i class="bi bi-arrow-repeat me-1"></i>Gentagende ydelse
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="col-xl-4 col-12">
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-receipt me-2"></i>Leverandørfakturaer</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadSupplierModule()">
|
|||
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0">
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-sm table-hover mb-0">
|
|||
|
|
<thead class="bg-light">
|
|||
|
|
<tr>
|
|||
|
|
<th class="ps-3">Faktura</th>
|
|||
|
|
<th>Beløb</th>
|
|||
|
|
<th>Status</th>
|
|||
|
|
<th class="text-end pe-3">Actions</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody id="supplierInvoicesBody">
|
|||
|
|
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Tidsforbrug Tab -->
|
|||
|
|
<div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-has-content="unknown" style="display:none;">
|
|||
|
|
<div id="timeActiveBanner" class="alert alert-warning d-none d-flex justify-content-between align-items-center" role="alert">
|
|||
|
|
<div>
|
|||
|
|
<strong>Aktiv timer:</strong>
|
|||
|
|
<span id="timeActiveBannerText">Du har en aktiv timer.</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="stopLiveTimerV1({ entry_status: 'kladde' })">Pause</button>
|
|||
|
|
<button class="btn btn-sm btn-primary" onclick="stopLiveTimerV1({ entry_status: 'afventer' })">Stop og registrer</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-stopwatch me-2"></i>Live tracking</h6>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="startLiveTimerV1()">
|
|||
|
|
<i class="bi bi-play-fill me-1"></i>Start timer
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({ entry_status: 'afventer' })">
|
|||
|
|
<i class="bi bi-stop-fill me-1"></i>Stop timer
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<form id="timeManualFormV1" class="row g-2 align-items-end" onsubmit="createManualTimeV1(event); return false;">
|
|||
|
|
<div class="col-xl-2 col-md-3 col-12">
|
|||
|
|
<label class="form-label small mb-1">Medarbejder</label>
|
|||
|
|
<select class="form-select form-select-sm" id="timeV1EmployeeId">
|
|||
|
|
<option value="">Mig (nuværende bruger)</option>
|
|||
|
|
{% for user in assignment_users %}
|
|||
|
|
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-xl-2 col-md-3 col-6">
|
|||
|
|
<label class="form-label small mb-1">Dato</label>
|
|||
|
|
<input type="date" class="form-control form-control-sm" id="timeV1Date">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-xl-1 col-md-2 col-3">
|
|||
|
|
<label class="form-label small mb-1">Start</label>
|
|||
|
|
<input type="time" class="form-control form-control-sm" id="timeV1Start">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-xl-1 col-md-2 col-3">
|
|||
|
|
<label class="form-label small mb-1">Slut</label>
|
|||
|
|
<input type="time" class="form-control form-control-sm" id="timeV1End">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-xl-1 col-md-2 col-6">
|
|||
|
|
<label class="form-label small mb-1">Minutt.</label>
|
|||
|
|
<input type="number" min="1" class="form-control form-control-sm" id="timeV1Minutes" placeholder="45" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-xl-2 col-md-6 col-6">
|
|||
|
|
<label class="form-label small mb-1">Beskrivelse</label>
|
|||
|
|
<input type="text" class="form-control form-control-sm" id="timeV1Description" placeholder="Hvad er udført?">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-xl-2 col-md-4 col-12 d-flex gap-1">
|
|||
|
|
<div class="w-50">
|
|||
|
|
<label class="form-label small mb-1">Type</label>
|
|||
|
|
<select class="form-select form-select-sm px-1" id="timeV1Type">
|
|||
|
|
<option value="ukendt">Ukendt</option>
|
|||
|
|
<option value="manuel" selected>Manuel</option>
|
|||
|
|
<option value="opkald">Opkald</option>
|
|||
|
|
<option value="mail">Mail</option>
|
|||
|
|
<option value="anydesk">AnyDesk</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="w-50">
|
|||
|
|
<label class="form-label small mb-1">Status</label>
|
|||
|
|
<select class="form-select form-select-sm px-1" id="timeV1Status">
|
|||
|
|
<option value="kladde">Kladde</option>
|
|||
|
|
<option value="afventer" selected>Afventer</option>
|
|||
|
|
<option value="godkendt">Godkendt</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-xl-1 col-md-2 col-12 d-grid">
|
|||
|
|
<button class="btn btn-sm btn-primary" type="submit" title="Tilføj registrering"><i class="bi bi-plus-lg fs-6"></i></button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
<div class="small text-muted mt-2">
|
|||
|
|
Faktisk tid gemmes internt. Fakturerbar tid afrundes per blok (standard 30 min).
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-lg-9">
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-diagram-3 me-2"></i>Timeline per medarbejder</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadTimeTrackingTab()">
|
|||
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Opdater
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" id="timeTimelineColumns">
|
|||
|
|
<div class="text-muted text-center py-3">Indlæser tidslinje...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-3">
|
|||
|
|
<div class="card">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-clipboard-data me-2"></i>Tids sammendrag</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body" id="timeSummaryEntries">
|
|||
|
|
<div class="text-muted small">Indlæser sammendrag...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="modal fade" id="timeV1EditModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title"><i class="bi bi-pencil-square me-2"></i>Detaljeret redigering af tid</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<input type="hidden" id="timeV1EditId">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Dato</label>
|
|||
|
|
<input type="date" class="form-control" id="timeV1EditDate">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Start</label>
|
|||
|
|
<input type="time" class="form-control" id="timeV1EditStart">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Slut</label>
|
|||
|
|
<input type="time" class="form-control" id="timeV1EditEnd">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Minutter</label>
|
|||
|
|
<input type="number" class="form-control" id="timeV1EditMinutes" min="1" step="1">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Medarbejder</label>
|
|||
|
|
<select class="form-select" id="timeV1EditEmployeeId">
|
|||
|
|
<option value="">Mig (nuværende bruger)</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4 d-flex align-items-end">
|
|||
|
|
<div class="form-check form-switch">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="timeV1EditBillable">
|
|||
|
|
<label class="form-check-label" for="timeV1EditBillable">Fakturerbar</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Type</label>
|
|||
|
|
<select class="form-select" id="timeV1EditType">
|
|||
|
|
<option value="ukendt">Ukendt</option>
|
|||
|
|
<option value="manuel">Manuel</option>
|
|||
|
|
<option value="opkald">Opkald</option>
|
|||
|
|
<option value="mail">Mail</option>
|
|||
|
|
<option value="anydesk">AnyDesk</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Status</label>
|
|||
|
|
<select class="form-select" id="timeV1EditStatus">
|
|||
|
|
<option value="kladde">Kladde</option>
|
|||
|
|
<option value="afventer">Afventer</option>
|
|||
|
|
<option value="godkendt">Godkendt</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Beskrivelse</label>
|
|||
|
|
<textarea class="form-control" id="timeV1EditDescription" rows="3" placeholder="Hvad er udført?"></textarea>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="small text-muted mt-3">Tip: Dobbeltklik på en blok i timeline eller en entry under "Uden tidsrum" for at åbne denne redigering.</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveTimeV1DetailedEdit()">
|
|||
|
|
<i class="bi bi-save me-1"></i>Gem ændringer
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Subscription Tab -->
|
|||
|
|
<div class="tab-pane fade" id="subscription" role="tabpanel" tabindex="0" data-module="subscription" data-has-content="unknown" style="display:none;">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-lg-8">
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-repeat me-2"></i>Abonnement</h6>
|
|||
|
|
<span id="subscriptionStatusBadge" class="badge bg-light text-dark">Ingen</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div id="subscriptionEmpty" class="text-center text-muted py-3">
|
|||
|
|
<i class="bi bi-receipt-cutoff display-6 mb-3 d-block opacity-25"></i>
|
|||
|
|
<p>Ingen abonnement oprettet endnu.</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="subscriptionDetails" class="d-none">
|
|||
|
|
<div class="row g-3 mb-3">
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="small text-muted">Abonnement</label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionNumber">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="small text-muted">Produkt</label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionProduct">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="small text-muted">Status</label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionStatusText">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="small text-muted">Interval</label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionInterval">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="small text-muted">Pris</label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionPrice">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="small text-muted">Startdato</label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionStartDate">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="small text-muted">Periode start <i class="bi bi-info-circle" title="Nuværende faktureringsperiode"></i></label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionPeriodStart">-</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="small text-muted">Næste faktura <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
|
|||
|
|
<div class="fw-semibold" id="subscriptionNextInvoice">-</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="table-responsive mb-3">
|
|||
|
|
<table class="table table-sm align-middle">
|
|||
|
|
<thead class="bg-light">
|
|||
|
|
<tr>
|
|||
|
|
<th>Produkt</th>
|
|||
|
|
<th>Beskrivelse</th>
|
|||
|
|
<th class="text-end">Antal</th>
|
|||
|
|
<th class="text-end">Enhedspris</th>
|
|||
|
|
<th class="text-end">Linjesum</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody id="subscriptionItemsBody">
|
|||
|
|
<tr>
|
|||
|
|
<td colspan="5" class="text-center text-muted">Ingen linjer</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex justify-content-end mb-3">
|
|||
|
|
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<form id="subscriptionCreateForm" class="row g-3 d-none">
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Interval *</label>
|
|||
|
|
<select class="form-select" id="subscriptionIntervalInput" required>
|
|||
|
|
<option value="daily">Daglig</option>
|
|||
|
|
<option value="biweekly">Hver 14. dag</option>
|
|||
|
|
<option value="monthly" selected>Maaned</option>
|
|||
|
|
<option value="quarterly">Kvartal</option>
|
|||
|
|
<option value="yearly">Aar</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Faktura dag *</label>
|
|||
|
|
<input type="number" class="form-control" id="subscriptionBillingDayInput" min="1" max="31" value="1" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Startdato *</label>
|
|||
|
|
<input type="date" class="form-control" id="subscriptionStartDateInput" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Varelinjer *</label>
|
|||
|
|
<div class="table-responsive">
|
|||
|
|
<table class="table table-sm align-middle mb-2">
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th style="width: 220px;">Produkt</th>
|
|||
|
|
<th>Beskrivelse</th>
|
|||
|
|
<th style="width: 120px;">Antal</th>
|
|||
|
|
<th style="width: 140px;">Enhedspris</th>
|
|||
|
|
<th style="width: 140px;">Linjesum</th>
|
|||
|
|
<th style="width: 60px;"></th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody id="subscriptionLineItemsBody">
|
|||
|
|
<tr>
|
|||
|
|
<td>
|
|||
|
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
|||
|
|
<option value="">Vælg produkt</option>
|
|||
|
|
</select>
|
|||
|
|
</td>
|
|||
|
|
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
|
|||
|
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
|||
|
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
|||
|
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
|||
|
|
<td class="text-end">
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex justify-content-between align-items-center">
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addSubscriptionLine()">
|
|||
|
|
<i class="bi bi-plus-lg me-1"></i>Tilfoej linje
|
|||
|
|
</button>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openSubscriptionProductModal()">
|
|||
|
|
<i class="bi bi-box me-1"></i>Opret produkt
|
|||
|
|
</button>
|
|||
|
|
<div class="fw-semibold">Total: <span id="subscriptionLinesTotal">0,00 kr</span></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-12">
|
|||
|
|
<label class="form-label">Noter</label>
|
|||
|
|
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="createSubscription()">
|
|||
|
|
<i class="bi bi-plus-circle me-1"></i>Opret abonnement
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Subscription Product Modal -->
|
|||
|
|
<div class="modal fade" id="subscriptionProductModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title"><i class="bi bi-box"></i> Opret produkt</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="subscriptionProductForm">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Navn *</label>
|
|||
|
|
<input type="text" class="form-control" id="subscriptionProductName" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Type</label>
|
|||
|
|
<input type="text" class="form-control" id="subscriptionProductType" placeholder="subscription, service">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label">Status</label>
|
|||
|
|
<select class="form-select" id="subscriptionProductStatus">
|
|||
|
|
<option value="active" selected>Aktiv</option>
|
|||
|
|
<option value="inactive">Inaktiv</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label">Salgspris</label>
|
|||
|
|
<input type="number" class="form-control" id="subscriptionProductSalesPrice" step="0.01" min="0">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Faktureringsinterval</label>
|
|||
|
|
<select class="form-select" id="subscriptionProductBillingPeriod">
|
|||
|
|
<option value="">-</option>
|
|||
|
|
<option value="daily">Daglig</option>
|
|||
|
|
<option value="biweekly">Hver 14. dag</option>
|
|||
|
|
<option value="monthly">Maaned</option>
|
|||
|
|
<option value="quarterly">Kvartal</option>
|
|||
|
|
<option value="yearly">Aar</option>
|
|||
|
|
<option value="one_time">Engang</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Kort beskrivelse</label>
|
|||
|
|
<input type="text" class="form-control" id="subscriptionProductDescription">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="createSubscriptionProduct()">
|
|||
|
|
<i class="bi bi-save me-1"></i>Gem produkt
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Reminders Tab -->
|
|||
|
|
<div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown" style="display:none;">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-lg-8">
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-bell me-2"></i>Reminders</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openCreateReminderModal()">
|
|||
|
|
<i class="bi bi-plus-lg me-1"></i>Opret reminder
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-0">
|
|||
|
|
<div class="list-group list-group-flush" id="remindersList">
|
|||
|
|
<div class="p-4 text-center text-muted">Indlæser reminders...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="card mb-3" id="caseCalendarCard" data-module="calendar" data-has-content="unknown">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-calendar3 me-2"></i>Kalenderaftaler</h6>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="openCreateReminderModal('meeting')">
|
|||
|
|
<i class="bi bi-plus-lg me-1"></i>Opret aftale
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<div class="small text-muted mb-2">Denne sag</div>
|
|||
|
|
<div class="list-group" id="caseCalendarCurrent">
|
|||
|
|
<div class="text-muted small">Indlæser aftaler...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<div class="small text-muted mb-2">Børnesager</div>
|
|||
|
|
<div id="caseCalendarChildren">
|
|||
|
|
<div class="text-muted small">Indlæser børnesager...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-lg-4">
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<h6 class="mb-0 text-primary"><i class="bi bi-sliders me-2"></i>Indstillinger</h6>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<p class="text-muted small mb-2">Reminders følger brugerens standardindstillinger (email, Mattermost og popup), medmindre du vælger at overskrive dem på reminderen.</p>
|
|||
|
|
<div class="small text-muted">
|
|||
|
|
Tip: Brug "Status ændring" hvis reminderen skal trigges af status.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="tab-pane fade" id="history" role="tabpanel" tabindex="0" data-module="history" data-has-content="true" style="display:none;">
|
|||
|
|
<div class="history-timeline-shell">
|
|||
|
|
<div class="history-timeline-toolbar">
|
|||
|
|
<label class="form-check-label small d-inline-flex align-items-center gap-2 me-2">
|
|||
|
|
<input class="form-check-input mt-0" type="checkbox" id="historyIncludeSubcasesToggle">
|
|||
|
|
Inkluder undersager
|
|||
|
|
</label>
|
|||
|
|
<input id="historyTimelineSearch" type="text" class="form-control form-control-sm" placeholder="Søg i historik..." style="max-width: 260px;">
|
|||
|
|
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|||
|
|
<input class="form-check-input mt-0" type="checkbox" id="historyFilterComments" checked>
|
|||
|
|
Kommentarer
|
|||
|
|
</label>
|
|||
|
|
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|||
|
|
<input class="form-check-input mt-0" type="checkbox" id="historyFilterDescription" checked>
|
|||
|
|
Beskrivelse
|
|||
|
|
</label>
|
|||
|
|
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|||
|
|
<input class="form-check-input mt-0" type="checkbox" id="historyFilterReminders" checked>
|
|||
|
|
Påmindelser
|
|||
|
|
</label>
|
|||
|
|
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|||
|
|
<input class="form-check-input mt-0" type="checkbox" id="historyFilterTime" checked>
|
|||
|
|
Tidsforbrug
|
|||
|
|
</label>
|
|||
|
|
<label class="form-check-label small d-inline-flex align-items-center gap-1">
|
|||
|
|
<input class="form-check-input mt-0" type="checkbox" id="historyFilterFieldChanges" checked>
|
|||
|
|
Feltændringer
|
|||
|
|
</label>
|
|||
|
|
<span class="ms-auto badge text-bg-light" id="historyTimelineCount">0</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="history-timeline-list" id="historyTimelineList">
|
|||
|
|
<div class="text-muted text-center py-4">Åbn fanen for at indlæse historik...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
|
|||
|
|
</div> <!-- End Tab Content -->
|
|||
|
|
|
|||
|
|
<!-- Create Reminder Modal -->
|
|||
|
|
<div class="modal fade" id="createReminderModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-lg">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Opret reminder</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="createReminderForm">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-md-8">
|
|||
|
|
<label class="form-label">Titel *</label>
|
|||
|
|
<input type="text" class="form-control" id="rem_title" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Prioritet</label>
|
|||
|
|
<select class="form-select" id="rem_priority">
|
|||
|
|
<option value="low">Lav</option>
|
|||
|
|
<option value="normal" selected>Normal</option>
|
|||
|
|
<option value="high">Høj</option>
|
|||
|
|
<option value="urgent">Kritisk</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Aftaletype</label>
|
|||
|
|
<select class="form-select" id="rem_event_type">
|
|||
|
|
<option value="reminder" selected>Reminder</option>
|
|||
|
|
<option value="meeting">Moede</option>
|
|||
|
|
<option value="technician_visit">Teknikerbesoeg</option>
|
|||
|
|
<option value="obs">OBS</option>
|
|||
|
|
<option value="deadline">Deadline</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Besked</label>
|
|||
|
|
<textarea class="form-control" id="rem_message" rows="3"></textarea>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Trigger type</label>
|
|||
|
|
<select class="form-select" id="rem_trigger_type" onchange="updateReminderTriggerFields()">
|
|||
|
|
<option value="time_based" selected>Tidspunkt</option>
|
|||
|
|
<option value="status_change">Status ændring</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6" id="rem_trigger_time_wrap">
|
|||
|
|
<label class="form-label">Tidspunkt</label>
|
|||
|
|
<input type="datetime-local" class="form-control" id="rem_scheduled_at">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6 d-none" id="rem_trigger_status_wrap">
|
|||
|
|
<label class="form-label">Status (target)</label>
|
|||
|
|
<select class="form-select" id="rem_target_status">
|
|||
|
|
<option value="">Vælg status</option>
|
|||
|
|
{% for status in status_options %}
|
|||
|
|
<option value="{{ status }}">{{ status }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Gentagelse</label>
|
|||
|
|
<select class="form-select" id="rem_recurrence_type" onchange="updateReminderRecurrenceFields()">
|
|||
|
|
<option value="once" selected>Kun én gang</option>
|
|||
|
|
<option value="daily">Dagligt</option>
|
|||
|
|
<option value="weekly">Ugentligt</option>
|
|||
|
|
<option value="monthly">Månedligt</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6 d-none" id="rem_recurrence_dow_wrap">
|
|||
|
|
<label class="form-label">Ugedag</label>
|
|||
|
|
<select class="form-select" id="rem_recurrence_dow">
|
|||
|
|
<option value="0">Mandag</option>
|
|||
|
|
<option value="1">Tirsdag</option>
|
|||
|
|
<option value="2">Onsdag</option>
|
|||
|
|
<option value="3">Torsdag</option>
|
|||
|
|
<option value="4">Fredag</option>
|
|||
|
|
<option value="5">Lørdag</option>
|
|||
|
|
<option value="6">Søndag</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6 d-none" id="rem_recurrence_dom_wrap">
|
|||
|
|
<label class="form-label">Dag i måned</label>
|
|||
|
|
<input type="number" class="form-control" id="rem_recurrence_dom" min="1" max="31" placeholder="Fx 15">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Kanaler</label>
|
|||
|
|
<div class="d-flex flex-wrap gap-3">
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="rem_notify_frontend" checked>
|
|||
|
|
<label class="form-check-label" for="rem_notify_frontend">Popup</label>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="rem_notify_email">
|
|||
|
|
<label class="form-check-label" for="rem_notify_email">E-mail</label>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="rem_notify_mattermost">
|
|||
|
|
<label class="form-check-label" for="rem_notify_mattermost">Mattermost</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="rem_override_prefs">
|
|||
|
|
<label class="form-check-label" for="rem_override_prefs">Overskriv brugerens standardindstillinger</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<div class="alert alert-warning small mb-0 d-none" id="rem_user_warning">
|
|||
|
|
Mangler bruger-id. Log ind igen eller opdater siden.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveReminder()">Gem reminder</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
function _escapeCommentHtml(value) {
|
|||
|
|
return String(value || '')
|
|||
|
|
.replace(/&/g, '&')
|
|||
|
|
.replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
.replace(/'/g, ''');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _removeQuotedMailLines(text) {
|
|||
|
|
const source = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|||
|
|
const lines = source.split('\n');
|
|||
|
|
const kept = [];
|
|||
|
|
|
|||
|
|
const headerRe = /^(fra|from|sent|date|dato|to|til|emne|subject|cc):\s*/i;
|
|||
|
|
const originalMessageRe = /^(original message|oprindelig besked|videresendt besked)/i;
|
|||
|
|
|
|||
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|||
|
|
const line = lines[i];
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
|
|||
|
|
if (trimmed.startsWith('>')) break;
|
|||
|
|
if (originalMessageRe.test(trimmed)) break;
|
|||
|
|
|
|||
|
|
if (/^[-_]{3,}$/.test(trimmed)) {
|
|||
|
|
const lookahead = lines.slice(i + 1, i + 4);
|
|||
|
|
if (lookahead.some((candidate) => headerRe.test(String(candidate || '').trim()))) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (i > 0 && headerRe.test(trimmed) && String(lines[i - 1] || '').trim() === '') {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
kept.push(line);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
while (kept.length > 0 && String(kept[kept.length - 1] || '').trim() === '') {
|
|||
|
|
kept.pop();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return kept.join('\n').trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _parseEmailComment(rawText) {
|
|||
|
|
const normalized = String(rawText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|||
|
|
const emailIdMatch = normalized.match(/^Email-ID:\s*(\d+)\s*$/m);
|
|||
|
|
const emailId = emailIdMatch ? Number(emailIdMatch[1]) : null;
|
|||
|
|
const withoutMeta = normalized.replace(/^Email-ID:\s*\d+\s*\n?/m, '').trim();
|
|||
|
|
return {
|
|||
|
|
emailId,
|
|||
|
|
visibleText: _removeQuotedMailLines(withoutMeta)
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _formatEmailHeaderTimestamp(value) {
|
|||
|
|
if (!value) return '';
|
|||
|
|
const parsed = new Date(value);
|
|||
|
|
if (Number.isNaN(parsed.getTime())) return String(value);
|
|||
|
|
return parsed.toLocaleString('da-DK');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _buildEmailHeaderAndBody(visibleText) {
|
|||
|
|
const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
|||
|
|
const lines = text.split('\n');
|
|||
|
|
|
|||
|
|
let idx = 0;
|
|||
|
|
let typeLabel = 'Indgaaende email';
|
|||
|
|
const firstLine = String(lines[0] || '').trim();
|
|||
|
|
if (/^📧\s*Indgående email/i.test(firstLine)) {
|
|||
|
|
typeLabel = 'Indgaaende email';
|
|||
|
|
idx = 1;
|
|||
|
|
} else if (/^📧\s*Udgående email/i.test(firstLine)) {
|
|||
|
|
typeLabel = 'Udgaaende email';
|
|||
|
|
idx = 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let fra = '';
|
|||
|
|
let til = '';
|
|||
|
|
let cc = '';
|
|||
|
|
let emne = '';
|
|||
|
|
let modtaget = '';
|
|||
|
|
|
|||
|
|
while (idx < lines.length) {
|
|||
|
|
const line = String(lines[idx] || '').trim();
|
|||
|
|
if (!line) {
|
|||
|
|
idx += 1;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim();
|
|||
|
|
else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim();
|
|||
|
|
else if (/^Cc:\s*/i.test(line)) cc = line.replace(/^Cc:\s*/i, '').trim();
|
|||
|
|
else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim();
|
|||
|
|
else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim();
|
|||
|
|
else break;
|
|||
|
|
idx += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const bodyText = lines.slice(idx).join('\n').trim();
|
|||
|
|
const summaryParts = [typeLabel];
|
|||
|
|
if (fra) summaryParts.push(`Fra: ${fra}`);
|
|||
|
|
if (til) summaryParts.push(`Til: ${til}`);
|
|||
|
|
if (cc) summaryParts.push(`Cc: ${cc}`);
|
|||
|
|
if (emne) summaryParts.push(`Emne: ${emne}`);
|
|||
|
|
if (modtaget) summaryParts.push(`Modtaget: ${_formatEmailHeaderTimestamp(modtaget)}`);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
summary: summaryParts.join(' • '),
|
|||
|
|
bodyText
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _extractEmailHeaderFields(visibleText) {
|
|||
|
|
const text = String(visibleText || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
|||
|
|
const lines = text.split('\n');
|
|||
|
|
let idx = 0;
|
|||
|
|
|
|||
|
|
const firstLine = String(lines[0] || '').trim();
|
|||
|
|
const isOutgoing = /^📧\s*Udgående email/i.test(firstLine);
|
|||
|
|
if (/^📧\s*(Indgående|Udgående)\s+email/i.test(firstLine)) {
|
|||
|
|
idx = 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let fra = '';
|
|||
|
|
let til = '';
|
|||
|
|
let emne = '';
|
|||
|
|
let modtaget = '';
|
|||
|
|
|
|||
|
|
while (idx < lines.length) {
|
|||
|
|
const line = String(lines[idx] || '').trim();
|
|||
|
|
if (!line) break;
|
|||
|
|
if (/^Fra:\s*/i.test(line)) fra = line.replace(/^Fra:\s*/i, '').trim();
|
|||
|
|
else if (/^Til:\s*/i.test(line)) til = line.replace(/^Til:\s*/i, '').trim();
|
|||
|
|
else if (/^Emne:\s*/i.test(line)) emne = line.replace(/^Emne:\s*/i, '').trim();
|
|||
|
|
else if (/^Modtaget:\s*/i.test(line)) modtaget = line.replace(/^Modtaget:\s*/i, '').trim();
|
|||
|
|
else break;
|
|||
|
|
idx += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { fra, til, emne, modtaget, isOutgoing };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _normalizeReplySubject(value) {
|
|||
|
|
const subject = String(value || '').trim();
|
|||
|
|
return subject.replace(/^(re|fw|fwd)\s*:\s*/ig, '').toLowerCase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _findBestLinkedEmailByHeader(header) {
|
|||
|
|
const targetSubject = _normalizeReplySubject(header?.emne || '');
|
|||
|
|
const targetFrom = String(header?.fra || '').trim().toLowerCase();
|
|||
|
|
const targetTo = String(header?.til || '').trim().toLowerCase();
|
|||
|
|
|
|||
|
|
const candidates = (linkedEmailsCache || []).filter((email) => {
|
|||
|
|
const emailSubject = _normalizeReplySubject(email?.subject || '');
|
|||
|
|
if (targetSubject && emailSubject !== targetSubject) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sender = String(email?.sender_email || email?.sender_name || '').toLowerCase();
|
|||
|
|
const recipient = String(email?.recipient_email || '').toLowerCase();
|
|||
|
|
|
|||
|
|
if (targetFrom && sender && sender.includes(targetFrom)) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
if (targetTo && recipient && recipient.includes(targetTo)) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return !targetFrom && !targetTo;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!candidates.length) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
candidates.sort((a, b) => {
|
|||
|
|
const aTs = a?.received_date ? new Date(a.received_date).getTime() : 0;
|
|||
|
|
const bTs = b?.received_date ? new Date(b.received_date).getTime() : 0;
|
|||
|
|
return bTs - aTs;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return Number(candidates[0]?.id) || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _extractEmailAddress(value) {
|
|||
|
|
const raw = String(value || '').trim();
|
|||
|
|
const match = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
|||
|
|
return match ? match[0] : raw;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _commentInitials(name) {
|
|||
|
|
const clean = String(name || '').trim();
|
|||
|
|
if (!clean) return 'EM';
|
|||
|
|
const parts = clean.split(/\s+/).filter(Boolean);
|
|||
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|||
|
|
return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _formatCommentTime(value) {
|
|||
|
|
const parsed = new Date(value || Date.now());
|
|||
|
|
if (Number.isNaN(parsed.getTime())) return '';
|
|||
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|||
|
|
return `${pad(parsed.getDate())}/${pad(parsed.getMonth() + 1)}-${parsed.getFullYear()} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _refreshCommentCountBadge() {
|
|||
|
|
const container = document.getElementById('comments-container');
|
|||
|
|
const badge = document.getElementById('commentsTotalCountBadge');
|
|||
|
|
if (!container || !badge) return;
|
|||
|
|
badge.textContent = String(container.querySelectorAll('.comment-item').length);
|
|||
|
|
applyCommentFilters();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _detectCommentSourceFromRaw(rawText) {
|
|||
|
|
const text = String(rawText || '').toLowerCase();
|
|||
|
|
|
|||
|
|
const emailPattern = /(\bemail\b|\be-mail\b|✉️|📧|email-kladde|mail\b|indgaaende email|udgaaende email|indgående email|udgående email)/i;
|
|||
|
|
const smsPattern = /(\bsms\b|💬)/i;
|
|||
|
|
const callPattern = /(\bopkald\b|\bring\b|\bringet\b|📞|click-to-call|yealink)/i;
|
|||
|
|
|
|||
|
|
if (emailPattern.test(text)) return 'email';
|
|||
|
|
if (smsPattern.test(text)) return 'sms';
|
|||
|
|
if (callPattern.test(text)) return 'call';
|
|||
|
|
return 'module';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _getCommentSource(item) {
|
|||
|
|
if (!item) return 'module';
|
|||
|
|
if (item.dataset?.source) return item.dataset.source;
|
|||
|
|
|
|||
|
|
const raw = String(item.querySelector('.comment-body')?.dataset.commentRaw || item.textContent || '');
|
|||
|
|
const source = _detectCommentSourceFromRaw(raw);
|
|||
|
|
item.dataset.source = source;
|
|||
|
|
return source;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _selectedCommentSources() {
|
|||
|
|
const selected = new Set();
|
|||
|
|
const defs = [
|
|||
|
|
['email', 'commentSourceFilterEmail'],
|
|||
|
|
['sms', 'commentSourceFilterSms'],
|
|||
|
|
['call', 'commentSourceFilterCall'],
|
|||
|
|
['module', 'commentSourceFilterModule']
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
defs.forEach(([source, id]) => {
|
|||
|
|
const el = document.getElementById(id);
|
|||
|
|
if (!el || el.checked) selected.add(source);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return selected;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _updateCommentSourceCounts() {
|
|||
|
|
const container = document.getElementById('comments-container');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
const sourceCounts = { email: 0, sms: 0, call: 0, module: 0 };
|
|||
|
|
container.querySelectorAll('.comment-item').forEach((item) => {
|
|||
|
|
const source = _getCommentSource(item);
|
|||
|
|
if (Object.prototype.hasOwnProperty.call(sourceCounts, source)) {
|
|||
|
|
sourceCounts[source] += 1;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sourceEmailEl = document.getElementById('commentSourceCountEmail');
|
|||
|
|
const sourceSmsEl = document.getElementById('commentSourceCountSms');
|
|||
|
|
const sourceCallEl = document.getElementById('commentSourceCountCall');
|
|||
|
|
const sourceModuleEl = document.getElementById('commentSourceCountModule');
|
|||
|
|
if (sourceEmailEl) sourceEmailEl.textContent = String(sourceCounts.email);
|
|||
|
|
if (sourceSmsEl) sourceSmsEl.textContent = String(sourceCounts.sms);
|
|||
|
|
if (sourceCallEl) sourceCallEl.textContent = String(sourceCounts.call);
|
|||
|
|
if (sourceModuleEl) sourceModuleEl.textContent = String(sourceCounts.module);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyCommentFilters() {
|
|||
|
|
const container = document.getElementById('comments-container');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
const search = String(document.getElementById('commentFilterText')?.value || '').trim().toLowerCase();
|
|||
|
|
const selectedSources = _selectedCommentSources();
|
|||
|
|
const items = Array.from(container.querySelectorAll('.comment-item'));
|
|||
|
|
|
|||
|
|
let visibleCount = 0;
|
|||
|
|
const totalCount = items.length;
|
|||
|
|
|
|||
|
|
items.forEach((item) => {
|
|||
|
|
const source = _getCommentSource(item);
|
|||
|
|
const author = String(item.querySelector('.comment-meta b')?.textContent || '').toLowerCase();
|
|||
|
|
const raw = String(item.querySelector('.comment-body')?.dataset.commentRaw || item.textContent || '').toLowerCase();
|
|||
|
|
|
|||
|
|
const matchesSource = selectedSources.has(source);
|
|||
|
|
const matchesSearch = !search || author.includes(search) || raw.includes(search);
|
|||
|
|
const visible = matchesSource && matchesSearch;
|
|||
|
|
|
|||
|
|
item.hidden = !visible;
|
|||
|
|
if (visible) visibleCount += 1;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const visibleBadge = document.getElementById('commentsVisibleCountBadge');
|
|||
|
|
if (visibleBadge) {
|
|||
|
|
visibleBadge.textContent = `${visibleCount}/${totalCount} vises`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const totalBadge = document.getElementById('commentsTotalCountBadge');
|
|||
|
|
if (totalBadge) {
|
|||
|
|
totalBadge.textContent = String(totalCount);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const emptyState = document.getElementById('commentFilterEmptyState');
|
|||
|
|
if (emptyState) {
|
|||
|
|
emptyState.classList.toggle('d-none', !(totalCount > 0 && visibleCount === 0));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_updateCommentSourceCounts();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bindCommentFilterControls() {
|
|||
|
|
const textInput = document.getElementById('commentFilterText');
|
|||
|
|
if (textInput) {
|
|||
|
|
textInput.addEventListener('input', applyCommentFilters);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
['commentSourceFilterEmail', 'commentSourceFilterSms', 'commentSourceFilterCall', 'commentSourceFilterModule'].forEach((id) => {
|
|||
|
|
const checkbox = document.getElementById(id);
|
|||
|
|
if (checkbox) {
|
|||
|
|
checkbox.addEventListener('change', applyCommentFilters);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function prependCommentToThread(comment) {
|
|||
|
|
const container = document.getElementById('comments-container');
|
|||
|
|
if (!container || !comment || !comment.indhold) return;
|
|||
|
|
|
|||
|
|
const emptyState = container.querySelector('p.text-center.text-muted.my-3');
|
|||
|
|
if (emptyState) emptyState.remove();
|
|||
|
|
|
|||
|
|
const author = String(comment.forfatter || 'Email Bot');
|
|||
|
|
const createdAtIso = String(comment.created_at || new Date().toISOString());
|
|||
|
|
const createdAtMs = new Date(createdAtIso).getTime();
|
|||
|
|
const createdAtUnix = Number.isFinite(createdAtMs) ? Math.floor(createdAtMs / 1000) : Math.floor(Date.now() / 1000);
|
|||
|
|
|
|||
|
|
const item = document.createElement('div');
|
|||
|
|
item.className = 'comment-item comment-system';
|
|||
|
|
item.dataset.category = 'system';
|
|||
|
|
item.dataset.source = _detectCommentSourceFromRaw(comment.indhold || '');
|
|||
|
|
item.dataset.createdAt = String(createdAtUnix);
|
|||
|
|
|
|||
|
|
const meta = document.createElement('div');
|
|||
|
|
meta.className = 'comment-meta';
|
|||
|
|
meta.innerHTML = `
|
|||
|
|
<span class="comment-avatar">${_escapeCommentHtml(_commentInitials(author))}</span>
|
|||
|
|
<b>${_escapeCommentHtml(author)}</b>
|
|||
|
|
<span class="comment-category-badge category-system">System</span>
|
|||
|
|
<span class="comment-time">${_escapeCommentHtml(_formatCommentTime(createdAtIso))}</span>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const body = document.createElement('div');
|
|||
|
|
body.className = 'comment-body';
|
|||
|
|
body.setAttribute('data-comment-raw', String(comment.indhold));
|
|||
|
|
body.textContent = String(comment.indhold);
|
|||
|
|
|
|||
|
|
item.appendChild(meta);
|
|||
|
|
item.appendChild(body);
|
|||
|
|
container.insertBefore(item, container.firstChild);
|
|||
|
|
|
|||
|
|
processCommentBodies();
|
|||
|
|
sortCommentsNewestFirst();
|
|||
|
|
_refreshCommentCountBadge();
|
|||
|
|
applyCommentFilters();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let activeCommentQuickReply = null;
|
|||
|
|
|
|||
|
|
window.closeInlineCommentQuickReply = function() {
|
|||
|
|
const host = document.getElementById('comment-quick-reply-host');
|
|||
|
|
if (host) host.innerHTML = '';
|
|||
|
|
activeCommentQuickReply = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.sendInlineCommentQuickReply = async function() {
|
|||
|
|
const host = document.getElementById('comment-quick-reply-host');
|
|||
|
|
const textarea = document.getElementById('commentQuickReplyText');
|
|||
|
|
const sendBtn = document.getElementById('commentQuickReplySendBtn');
|
|||
|
|
const statusEl = document.getElementById('commentQuickReplyStatus');
|
|||
|
|
if (!host || !textarea || !sendBtn || !statusEl || !activeCommentQuickReply) return;
|
|||
|
|
|
|||
|
|
const bodyText = String(textarea.value || '').trim();
|
|||
|
|
if (!bodyText) {
|
|||
|
|
statusEl.className = 'comment-quick-reply-status text-danger';
|
|||
|
|
statusEl.textContent = 'Skriv et svar';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const recipient = _extractEmailAddress(activeCommentQuickReply.recipient);
|
|||
|
|
if (!recipient || recipient.indexOf('@') === -1) {
|
|||
|
|
statusEl.className = 'comment-quick-reply-status text-danger';
|
|||
|
|
statusEl.textContent = 'Ingen gyldig modtager fundet i kommentaren';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sendBtn.disabled = true;
|
|||
|
|
statusEl.className = 'comment-quick-reply-status';
|
|||
|
|
statusEl.textContent = 'Sender...';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await loadLinkedEmails();
|
|||
|
|
|
|||
|
|
let threadEmailId = Number(activeCommentQuickReply.emailId) || null;
|
|||
|
|
if (!threadEmailId) {
|
|||
|
|
threadEmailId = _findBestLinkedEmailByHeader(activeCommentQuickReply.header);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let threadKey = null;
|
|||
|
|
if (threadEmailId) {
|
|||
|
|
const linked = linkedEmailsCache.find((entry) => Number(entry.id) === Number(threadEmailId));
|
|||
|
|
threadKey = linked?.resolved_thread_key || linked?.thread_key || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseIds}/emails/send`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
to: [recipient],
|
|||
|
|
subject: activeCommentQuickReply.subject,
|
|||
|
|
body_text: bodyText,
|
|||
|
|
thread_email_id: threadEmailId,
|
|||
|
|
thread_key: threadKey
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
let message = `HTTP ${response.status}`;
|
|||
|
|
try {
|
|||
|
|
const payload = await response.json();
|
|||
|
|
message = payload?.detail || payload?.message || message;
|
|||
|
|
} catch (_) {
|
|||
|
|
}
|
|||
|
|
throw new Error(message);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await response.json();
|
|||
|
|
if (result?.comment) {
|
|||
|
|
prependCommentToThread(result.comment);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
statusEl.className = 'comment-quick-reply-status text-success';
|
|||
|
|
statusEl.textContent = 'Svar sendt';
|
|||
|
|
textarea.value = '';
|
|||
|
|
await loadLinkedEmails();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
window.closeInlineCommentQuickReply();
|
|||
|
|
}, 500);
|
|||
|
|
} catch (error) {
|
|||
|
|
statusEl.className = 'comment-quick-reply-status text-danger';
|
|||
|
|
statusEl.textContent = error?.message || 'Kunne ikke sende svar';
|
|||
|
|
} finally {
|
|||
|
|
sendBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openInlineCommentQuickReply(rawText, emailId) {
|
|||
|
|
const host = document.getElementById('comment-quick-reply-host');
|
|||
|
|
if (!host) return;
|
|||
|
|
|
|||
|
|
const parsed = _parseEmailComment(rawText || '');
|
|||
|
|
const header = _extractEmailHeaderFields(parsed.visibleText || '');
|
|||
|
|
const fallbackRecipient = header.isOutgoing ? (header.til || header.fra) : (header.fra || header.til);
|
|||
|
|
const subject = /^re:\s*/i.test(header.emne || '')
|
|||
|
|
? (header.emne || `Sag #${caseIds}`)
|
|||
|
|
: `Re: ${header.emne || `Sag #${caseIds}`}`;
|
|||
|
|
|
|||
|
|
activeCommentQuickReply = {
|
|||
|
|
rawText,
|
|||
|
|
header,
|
|||
|
|
emailId: Number(emailId) || parsed.emailId || null,
|
|||
|
|
recipient: fallbackRecipient,
|
|||
|
|
subject
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
host.innerHTML = `
|
|||
|
|
<div class="comment-quick-reply-box">
|
|||
|
|
<div class="small text-muted mb-1">Quick svar til ${_escapeCommentHtml(String(fallbackRecipient || 'ukendt modtager'))}</div>
|
|||
|
|
<textarea id="commentQuickReplyText" class="form-control" rows="2" placeholder="Skriv hurtigt svar..."></textarea>
|
|||
|
|
<div class="comment-quick-reply-actions">
|
|||
|
|
<div id="commentQuickReplyStatus" class="comment-quick-reply-status"></div>
|
|||
|
|
<div class="d-flex gap-2">
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="closeInlineCommentQuickReply()">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-sm btn-primary" id="commentQuickReplySendBtn" onclick="sendInlineCommentQuickReply()"><i class="bi bi-send me-1"></i>Send</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const textarea = document.getElementById('commentQuickReplyText');
|
|||
|
|
if (textarea) {
|
|||
|
|
textarea.focus();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function quickReplyToEmailFromCommentText(rawText) {
|
|||
|
|
openCaseEmailTab();
|
|||
|
|
|
|||
|
|
const parsed = _parseEmailComment(rawText || '');
|
|||
|
|
const header = _extractEmailHeaderFields(parsed.visibleText || '');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await loadLinkedEmails();
|
|||
|
|
|
|||
|
|
const matchedEmailId = _findBestLinkedEmailByHeader(header);
|
|||
|
|
if (matchedEmailId) {
|
|||
|
|
await loadLinkedEmailDetail(matchedEmailId);
|
|||
|
|
openReplyToLinkedEmail();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Kunne ikke finde trådmail fra kommentar:', error);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|||
|
|
if (!composeModalEl) return;
|
|||
|
|
|
|||
|
|
const toInput = document.getElementById('caseEmailTo');
|
|||
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|||
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|||
|
|
|
|||
|
|
const fallbackRecipient = (header.isOutgoing ? header.til : header.fra) || header.fra || header.til || '';
|
|||
|
|
if (toInput && !toInput.value.trim() && fallbackRecipient) {
|
|||
|
|
toInput.value = fallbackRecipient;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (subjectInput && !subjectInput.value.trim()) {
|
|||
|
|
subjectInput.value = escapeHtmlForInput(
|
|||
|
|
/^re:\s*/i.test(header.emne || '')
|
|||
|
|
? (header.emne || `Sag #${caseIds}`)
|
|||
|
|
: `Re: ${header.emne || `Sag #${caseIds}`}`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (bodyInput && !bodyInput.value.trim()) {
|
|||
|
|
bodyInput.value = `\n\n---\nFra: ${header.fra || '-'}\nDato: ${header.modtaget || '-'}\nEmne: ${header.emne || '(Ingen emne)'}\n`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openEmailFromComment(emailId) {
|
|||
|
|
const parsedId = Number(emailId);
|
|||
|
|
if (!Number.isFinite(parsedId)) return;
|
|||
|
|
|
|||
|
|
if (typeof openCaseEmailTab === 'function') {
|
|||
|
|
openCaseEmailTab();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (typeof loadLinkedEmails === 'function') {
|
|||
|
|
await loadLinkedEmails();
|
|||
|
|
}
|
|||
|
|
if (typeof loadLinkedEmailDetail === 'function') {
|
|||
|
|
await loadLinkedEmailDetail(parsedId);
|
|||
|
|
}
|
|||
|
|
const emailTabPane = document.getElementById('emails');
|
|||
|
|
if (emailTabPane) {
|
|||
|
|
emailTabPane.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Kunne ikke åbne email fra kommentar:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function processCommentBodies() {
|
|||
|
|
const commentItems = Array.from(document.querySelectorAll('#comments-container .comment-item'));
|
|||
|
|
commentItems.forEach((item) => {
|
|||
|
|
const body = item.querySelector('.comment-body');
|
|||
|
|
if (!body) return;
|
|||
|
|
|
|||
|
|
const rawText = body.dataset.commentRaw || body.textContent || '';
|
|||
|
|
if (!item.classList.contains('comment-system')) {
|
|||
|
|
body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, '<br>');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasEmailHeader = /(^|\n)\s*📧\s*(Indgående|Udgående)\s+email/i.test(String(rawText));
|
|||
|
|
if (!hasEmailHeader) {
|
|||
|
|
body.innerHTML = _escapeCommentHtml(String(rawText)).replace(/\n/g, '<br>');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parsed = _parseEmailComment(rawText);
|
|||
|
|
const display = _buildEmailHeaderAndBody(parsed.visibleText || '');
|
|||
|
|
const safeHeader = _escapeCommentHtml(display.summary || 'Indgaaende email');
|
|||
|
|
const safeBody = _escapeCommentHtml(display.bodyText || '').replace(/\n/g, '<br>');
|
|||
|
|
body.innerHTML = `
|
|||
|
|
<div class="comment-email-header" title="${safeHeader}">${safeHeader}</div>
|
|||
|
|
${display.bodyText ? `<div class="comment-email-text">${safeBody}</div>` : ''}
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const existingActions = item.querySelector('.comment-actions');
|
|||
|
|
if (existingActions) {
|
|||
|
|
existingActions.remove();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (parsed.emailId) {
|
|||
|
|
const actions = document.createElement('div');
|
|||
|
|
actions.className = 'comment-actions';
|
|||
|
|
actions.innerHTML = `
|
|||
|
|
<button type="button" class="btn btn-link btn-sm" onclick="openEmailFromComment(${parsed.emailId})"><i class="bi bi-envelope-open me-1"></i>Aabn fuld mail</button>
|
|||
|
|
<button type="button" class="btn btn-link btn-sm" onclick="quickReplyToEmailFromComment(${parsed.emailId})"><i class="bi bi-reply me-1"></i>Svar</button>
|
|||
|
|
<button type="button" class="btn btn-link btn-sm js-quick-inline-reply"><i class="bi bi-lightning-charge me-1"></i>Quick svar</button>
|
|||
|
|
`;
|
|||
|
|
item.appendChild(actions);
|
|||
|
|
const quickInlineBtn = actions.querySelector('.js-quick-inline-reply');
|
|||
|
|
if (quickInlineBtn) {
|
|||
|
|
quickInlineBtn.addEventListener('click', () => {
|
|||
|
|
openInlineCommentQuickReply(rawText, parsed.emailId);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
const actions = document.createElement('div');
|
|||
|
|
actions.className = 'comment-actions';
|
|||
|
|
actions.innerHTML = `
|
|||
|
|
<button type="button" class="btn btn-link btn-sm" onclick="openCaseEmailTab()"><i class="bi bi-envelope me-1"></i>Aabn email-fane</button>
|
|||
|
|
<button type="button" class="btn btn-link btn-sm js-reply-fallback"><i class="bi bi-reply me-1"></i>Svar</button>
|
|||
|
|
<button type="button" class="btn btn-link btn-sm js-quick-reply-fallback"><i class="bi bi-lightning-charge me-1"></i>Quick svar</button>
|
|||
|
|
`;
|
|||
|
|
item.appendChild(actions);
|
|||
|
|
const replyBtn = actions.querySelector('.js-reply-fallback');
|
|||
|
|
if (replyBtn) {
|
|||
|
|
replyBtn.addEventListener('click', () => {
|
|||
|
|
quickReplyToEmailFromCommentText(rawText);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const quickReplyBtn = actions.querySelector('.js-quick-reply-fallback');
|
|||
|
|
if (quickReplyBtn) {
|
|||
|
|
quickReplyBtn.addEventListener('click', () => {
|
|||
|
|
openInlineCommentQuickReply(rawText, null);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sortCommentsNewestFirst() {
|
|||
|
|
const container = document.getElementById('comments-container');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
const items = Array.from(container.querySelectorAll('.comment-item'));
|
|||
|
|
if (items.length < 2) return;
|
|||
|
|
|
|||
|
|
items
|
|||
|
|
.sort((a, b) => Number(b.dataset.createdAt || 0) - Number(a.dataset.createdAt || 0))
|
|||
|
|
.forEach((item) => container.appendChild(item));
|
|||
|
|
|
|||
|
|
applyCommentFilters();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function submitComment(event) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
const form = event.target;
|
|||
|
|
const content = form.indhold.value;
|
|||
|
|
const btn = form.querySelector('button');
|
|||
|
|
const originalText = btn.innerHTML;
|
|||
|
|
|
|||
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Sender...';
|
|||
|
|
btn.disabled = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/sag/{{ case.id }}/kommentarer', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
indhold: content
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert('Fejl ved oprettelse af kommentar');
|
|||
|
|
btn.innerHTML = originalText;
|
|||
|
|
btn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error:', error);
|
|||
|
|
alert('Der skete en fejl. Prøv igen.');
|
|||
|
|
btn.innerHTML = originalText;
|
|||
|
|
btn.disabled = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Keep newest comments visible at top
|
|||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|||
|
|
sortCommentsNewestFirst();
|
|||
|
|
processCommentBodies();
|
|||
|
|
bindCommentFilterControls();
|
|||
|
|
applyCommentFilters();
|
|||
|
|
const container = document.getElementById('comments-container');
|
|||
|
|
if(container) {
|
|||
|
|
container.scrollTop = 0;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
let caseHistoryTimelineCache = [];
|
|||
|
|
let caseHistoryTimelineLoadedKey = null;
|
|||
|
|
|
|||
|
|
function _historyEscape(value) {
|
|||
|
|
return String(value || '')
|
|||
|
|
.replace(/&/g, '&')
|
|||
|
|
.replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
.replace(/'/g, ''');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _historyIconFor(event) {
|
|||
|
|
if ((event?.event_type || '') === 'description') return 'bi-file-earmark-text';
|
|||
|
|
if ((event?.event_type || '') === 'reminder') return 'bi-bell';
|
|||
|
|
if ((event?.event_type || '') === 'time') return 'bi-clock-history';
|
|||
|
|
if ((event?.event_type || '') === 'field_change') return 'bi-sliders2';
|
|||
|
|
if ((event?.source || '') === 'email') return 'bi-envelope';
|
|||
|
|
if ((event?.source || '') === 'sms') return 'bi-chat-dots';
|
|||
|
|
if ((event?.source || '') === 'call') return 'bi-telephone';
|
|||
|
|
return 'bi-journal-text';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _historyFormatTime(value) {
|
|||
|
|
const dt = new Date(value || Date.now());
|
|||
|
|
if (Number.isNaN(dt.getTime())) return '-';
|
|||
|
|
return dt.toLocaleString('da-DK', {
|
|||
|
|
day: '2-digit',
|
|||
|
|
month: '2-digit',
|
|||
|
|
year: 'numeric',
|
|||
|
|
hour: '2-digit',
|
|||
|
|
minute: '2-digit'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCaseHistoryTimeline() {
|
|||
|
|
const list = document.getElementById('historyTimelineList');
|
|||
|
|
if (!list) return;
|
|||
|
|
|
|||
|
|
const search = String(document.getElementById('historyTimelineSearch')?.value || '').trim().toLowerCase();
|
|||
|
|
const includeComments = document.getElementById('historyFilterComments')?.checked !== false;
|
|||
|
|
const includeDescription = document.getElementById('historyFilterDescription')?.checked !== false;
|
|||
|
|
const includeReminders = document.getElementById('historyFilterReminders')?.checked !== false;
|
|||
|
|
const includeTime = document.getElementById('historyFilterTime')?.checked !== false;
|
|||
|
|
const includeFieldChanges = document.getElementById('historyFilterFieldChanges')?.checked !== false;
|
|||
|
|
|
|||
|
|
const filtered = (caseHistoryTimelineCache || []).filter((event) => {
|
|||
|
|
if (event.event_type === 'comment' && !includeComments) return false;
|
|||
|
|
if (event.event_type === 'description' && !includeDescription) return false;
|
|||
|
|
if (event.event_type === 'reminder' && !includeReminders) return false;
|
|||
|
|
if (event.event_type === 'time' && !includeTime) return false;
|
|||
|
|
if (event.event_type === 'field_change' && !includeFieldChanges) return false;
|
|||
|
|
|
|||
|
|
if (!search) return true;
|
|||
|
|
|
|||
|
|
const haystack = [
|
|||
|
|
event.title,
|
|||
|
|
event.description,
|
|||
|
|
event.sag_titel,
|
|||
|
|
event.forfatter,
|
|||
|
|
event.source,
|
|||
|
|
].join(' ').toLowerCase();
|
|||
|
|
|
|||
|
|
return haystack.includes(search);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const counter = document.getElementById('historyTimelineCount');
|
|||
|
|
if (counter) counter.textContent = `${filtered.length}`;
|
|||
|
|
|
|||
|
|
if (!filtered.length) {
|
|||
|
|
list.innerHTML = '<div class="text-muted text-center py-4">Ingen historik matcher de valgte filtre.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
list.innerHTML = filtered.map((event) => {
|
|||
|
|
const icon = _historyIconFor(event);
|
|||
|
|
const ts = _historyFormatTime(event.timestamp);
|
|||
|
|
const caseMeta = event.sag_id ? `Sag #${event.sag_id}${event.sag_titel ? ` · ${_historyEscape(event.sag_titel)}` : ''}` : '';
|
|||
|
|
const sourceBadge = event.source ? `<span class="badge text-bg-light border">${_historyEscape(event.source)}</span>` : '';
|
|||
|
|
const isFieldChange = (event?.event_type || '') === 'field_change';
|
|||
|
|
const subtypeBadge = event.event_subtype
|
|||
|
|
? `<span class="badge ${isFieldChange ? 'history-badge-field-change' : 'text-bg-secondary'}">${_historyEscape(event.event_subtype)}</span>`
|
|||
|
|
: '';
|
|||
|
|
const itemClass = isFieldChange ? 'history-timeline-item field-change' : 'history-timeline-item';
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="${itemClass}">
|
|||
|
|
<div class="history-item-meta">
|
|||
|
|
<i class="bi ${icon}"></i>
|
|||
|
|
<span>${ts}</span>
|
|||
|
|
${sourceBadge}
|
|||
|
|
${subtypeBadge}
|
|||
|
|
${caseMeta ? `<span class="text-muted">${caseMeta}</span>` : ''}
|
|||
|
|
</div>
|
|||
|
|
<div class="history-item-title">${_historyEscape(event.title || 'Hændelse')}</div>
|
|||
|
|
<div class="history-item-body">${_historyEscape(event.description || '')}</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadCaseHistoryTimeline(forceReload = false) {
|
|||
|
|
const includeSubcases = !!document.getElementById('historyIncludeSubcasesToggle')?.checked;
|
|||
|
|
const loadKey = includeSubcases ? 'with_subcases' : 'case_only';
|
|||
|
|
const list = document.getElementById('historyTimelineList');
|
|||
|
|
if (!list) return;
|
|||
|
|
|
|||
|
|
if (!forceReload && caseHistoryTimelineLoadedKey === loadKey) {
|
|||
|
|
renderCaseHistoryTimeline();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
list.innerHTML = '<div class="text-muted text-center py-4"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser historik...</div>';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/timeline?include_subcases=${includeSubcases ? 'true' : 'false'}`);
|
|||
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|||
|
|
const payload = await res.json();
|
|||
|
|
caseHistoryTimelineCache = Array.isArray(payload?.events) ? payload.events : [];
|
|||
|
|
caseHistoryTimelineLoadedKey = loadKey;
|
|||
|
|
|
|||
|
|
if (typeof _setCaseTabCountBadge === 'function') {
|
|||
|
|
_setCaseTabCountBadge('historyTabCountBadge', caseHistoryTimelineCache.length);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderCaseHistoryTimeline();
|
|||
|
|
} catch (error) {
|
|||
|
|
list.innerHTML = '<div class="text-danger text-center py-4">Kunne ikke indlæse historik.</div>';
|
|||
|
|
console.error('Historik-timeline fejl:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
const historyTab = document.getElementById('history-tab');
|
|||
|
|
if (historyTab) {
|
|||
|
|
historyTab.addEventListener('shown.bs.tab', () => loadCaseHistoryTimeline(false));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const includeToggle = document.getElementById('historyIncludeSubcasesToggle');
|
|||
|
|
if (includeToggle) {
|
|||
|
|
includeToggle.addEventListener('change', () => loadCaseHistoryTimeline(true));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const searchInput = document.getElementById('historyTimelineSearch');
|
|||
|
|
if (searchInput) {
|
|||
|
|
searchInput.addEventListener('input', renderCaseHistoryTimeline);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
['historyFilterComments', 'historyFilterDescription', 'historyFilterReminders', 'historyFilterTime', 'historyFilterFieldChanges'].forEach((id) => {
|
|||
|
|
const el = document.getElementById(id);
|
|||
|
|
if (el) el.addEventListener('change', renderCaseHistoryTimeline);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const salesCaseId = {{ case.id }};
|
|||
|
|
|
|||
|
|
function formatCurrency(value) {
|
|||
|
|
const num = Number(value || 0);
|
|||
|
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(num);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatNumber(value) {
|
|||
|
|
const num = Number(value || 0);
|
|||
|
|
return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(num);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let saleItemsCache = [];
|
|||
|
|
|
|||
|
|
const purchasePurposeLabels = {
|
|||
|
|
salg: 'Salg',
|
|||
|
|
lager: 'Lager',
|
|||
|
|
asset: 'Asset',
|
|||
|
|
intern_brug: 'Intern brug',
|
|||
|
|
retur_reklamation: 'Retur/reklamation',
|
|||
|
|
projekt_omkostning: 'Projektomkostning'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function purchasePurposeLabel(value) {
|
|||
|
|
if (!value) return '-';
|
|||
|
|
return purchasePurposeLabels[value] || value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadVarekobSalg() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${salesCaseId}/varekob-salg?include_subcases=true`);
|
|||
|
|
if (!res.ok) throw new Error('Failed to load aggregated data');
|
|||
|
|
const data = await res.json();
|
|||
|
|
|
|||
|
|
document.getElementById('salesTotalPurchase').textContent = formatCurrency(data?.totals?.purchase_total);
|
|||
|
|
document.getElementById('salesTotalSale').textContent = formatCurrency(data?.totals?.sale_total);
|
|||
|
|
document.getElementById('salesTotalNet').textContent = formatCurrency(data?.totals?.net_total);
|
|||
|
|
document.getElementById('salesTotalHours').textContent = formatNumber(data?.totals?.total_hours) + ' t';
|
|||
|
|
document.getElementById('salesBillableHours').textContent = formatNumber(data?.totals?.billable_hours) + ' t';
|
|||
|
|
|
|||
|
|
saleItemsCache = data.sale_items || [];
|
|||
|
|
renderSaleItems(saleItemsCache);
|
|||
|
|
renderTimeEntries(data.time_entries || []);
|
|||
|
|
const hasSalesData = (data.sale_items || []).length > 0 || (data.time_entries || []).length > 0;
|
|||
|
|
setModuleContentState('sales', hasSalesData);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(error);
|
|||
|
|
const saleBody = document.getElementById('saleItemsBody');
|
|||
|
|
if (saleBody) {
|
|||
|
|
saleBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
|
|||
|
|
}
|
|||
|
|
const timeBody = document.getElementById('salesTimeBody');
|
|||
|
|
if (timeBody) {
|
|||
|
|
timeBody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
|
|||
|
|
}
|
|||
|
|
setModuleContentState('sales', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSaleItems(items) {
|
|||
|
|
const salesBody = document.getElementById('saleItemsSalesBody');
|
|||
|
|
const purchaseBody = document.getElementById('saleItemsPurchaseBody');
|
|||
|
|
const salesSubtotal = document.getElementById('salesLinesSubtotal');
|
|||
|
|
const purchaseSubtotal = document.getElementById('purchaseLinesSubtotal');
|
|||
|
|
if (!salesBody || !purchaseBody) return;
|
|||
|
|
|
|||
|
|
const salesItems = items.filter(item => (item.type || '').toLowerCase() !== 'purchase');
|
|||
|
|
const purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase');
|
|||
|
|
|
|||
|
|
const renderRows = (list, isPurchase) => {
|
|||
|
|
if (!list.length) {
|
|||
|
|
const columns = isPurchase ? 10 : 9;
|
|||
|
|
return `<tr><td colspan="${columns}" class="text-center py-4 text-muted">Ingen linjer</td></tr>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return list.map(item => {
|
|||
|
|
const statusLabel = item.status || 'draft';
|
|||
|
|
const isSubcase = item.sag_id && item.sag_id !== salesCaseId;
|
|||
|
|
const sourceBadge = isSubcase
|
|||
|
|
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
|
|||
|
|
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
|
|||
|
|
const purposeCell = isPurchase
|
|||
|
|
? `<td><span class="badge bg-info-subtle text-dark border">${purchasePurposeLabel(item.purchase_purpose)}</span></td>`
|
|||
|
|
: '';
|
|||
|
|
return `
|
|||
|
|
<tr>
|
|||
|
|
<td class="ps-4">${item.line_date || '-'}</td>
|
|||
|
|
<td>${item.description || '-'}</td>
|
|||
|
|
<td>${item.quantity ?? '-'}</td>
|
|||
|
|
<td>${item.unit || '-'}</td>
|
|||
|
|
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
|
|||
|
|
<td class="fw-bold">${formatCurrency(item.amount)}</td>
|
|||
|
|
${purposeCell}
|
|||
|
|
<td>${item.source_sag_titel || '-'}${sourceBadge}</td>
|
|||
|
|
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
|
|||
|
|
<td class="text-end pe-4">
|
|||
|
|
<div class="btn-group btn-group-sm" role="group">
|
|||
|
|
<button class="btn btn-outline-secondary" onclick='openSaleItemModalById(${item.id})'><i class="bi bi-pencil"></i></button>
|
|||
|
|
<button class="btn btn-outline-danger" onclick='deleteSaleItem(${item.id})'><i class="bi bi-trash"></i></button>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
salesBody.innerHTML = renderRows(salesItems, false);
|
|||
|
|
purchaseBody.innerHTML = renderRows(purchaseItems, true);
|
|||
|
|
|
|||
|
|
const salesSum = salesItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
|||
|
|
const purchaseSum = purchaseItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
|||
|
|
if (salesSubtotal) salesSubtotal.textContent = formatCurrency(salesSum);
|
|||
|
|
if (purchaseSubtotal) purchaseSubtotal.textContent = formatCurrency(purchaseSum);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTimeEntries(entries) {
|
|||
|
|
const tbody = document.getElementById('salesTimeBody');
|
|||
|
|
if (!tbody) return;
|
|||
|
|
if (!entries.length) {
|
|||
|
|
tbody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Ingen tid registreret</td></tr>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tbody.innerHTML = entries.map(entry => {
|
|||
|
|
const hours = entry.approved_hours || entry.original_hours || 0;
|
|||
|
|
const isSubcase = entry.sag_id && entry.sag_id !== salesCaseId;
|
|||
|
|
const sourceBadge = isSubcase
|
|||
|
|
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
|
|||
|
|
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
|
|||
|
|
return `
|
|||
|
|
<tr>
|
|||
|
|
<td class="ps-3">${entry.worked_date || '-'}</td>
|
|||
|
|
<td>${formatNumber(hours)} t</td>
|
|||
|
|
<td>${entry.source_sag_titel || '-'}${sourceBadge}</td>
|
|||
|
|
</tr>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openSaleItemModal(item = null) {
|
|||
|
|
document.getElementById('sale_item_id').value = item?.id || '';
|
|||
|
|
document.getElementById('sale_type').value = item?.type || 'sale';
|
|||
|
|
document.getElementById('sale_status').value = item?.status || 'draft';
|
|||
|
|
document.getElementById('sale_date').value = item?.line_date || '';
|
|||
|
|
document.getElementById('sale_description').value = item?.description || '';
|
|||
|
|
document.getElementById('sale_quantity').value = item?.quantity ?? '';
|
|||
|
|
document.getElementById('sale_unit').value = item?.unit || '';
|
|||
|
|
document.getElementById('sale_unit_price').value = item?.unit_price ?? '';
|
|||
|
|
document.getElementById('sale_amount').value = item?.amount ?? '';
|
|||
|
|
document.getElementById('sale_currency').value = item?.currency || 'DKK';
|
|||
|
|
document.getElementById('sale_external_ref').value = item?.external_ref || '';
|
|||
|
|
document.getElementById('sale_purchase_purpose').value = item?.purchase_purpose || '';
|
|||
|
|
document.getElementById('sale_supplier_invoice_id').value = item?.supplier_invoice_id || '';
|
|||
|
|
document.getElementById('sale_supplier_invoice_line_id').value = item?.supplier_invoice_line_id || '';
|
|||
|
|
|
|||
|
|
togglePurchaseFields();
|
|||
|
|
|
|||
|
|
new bootstrap.Modal(document.getElementById('saleItemModal')).show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function togglePurchaseFields() {
|
|||
|
|
const type = document.getElementById('sale_type').value;
|
|||
|
|
const purchaseFields = document.getElementById('sale_purchase_fields');
|
|||
|
|
if (!purchaseFields) return;
|
|||
|
|
|
|||
|
|
if (type === 'purchase') {
|
|||
|
|
purchaseFields.classList.remove('d-none');
|
|||
|
|
} else {
|
|||
|
|
purchaseFields.classList.add('d-none');
|
|||
|
|
document.getElementById('sale_purchase_purpose').value = '';
|
|||
|
|
document.getElementById('sale_supplier_invoice_id').value = '';
|
|||
|
|
document.getElementById('sale_supplier_invoice_line_id').value = '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openSaleItemModalById(itemId) {
|
|||
|
|
const item = saleItemsCache.find((entry) => entry.id === itemId);
|
|||
|
|
openSaleItemModal(item || null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateSaleAmount() {
|
|||
|
|
const qty = parseFloat(document.getElementById('sale_quantity').value || 0);
|
|||
|
|
const price = parseFloat(document.getElementById('sale_unit_price').value || 0);
|
|||
|
|
if (qty && price) {
|
|||
|
|
document.getElementById('sale_amount').value = (qty * price).toFixed(2);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveSaleItem() {
|
|||
|
|
const itemId = document.getElementById('sale_item_id').value;
|
|||
|
|
const payload = {
|
|||
|
|
type: document.getElementById('sale_type').value,
|
|||
|
|
status: document.getElementById('sale_status').value,
|
|||
|
|
line_date: document.getElementById('sale_date').value || null,
|
|||
|
|
description: document.getElementById('sale_description').value,
|
|||
|
|
quantity: document.getElementById('sale_quantity').value || null,
|
|||
|
|
unit: document.getElementById('sale_unit').value || null,
|
|||
|
|
unit_price: document.getElementById('sale_unit_price').value || null,
|
|||
|
|
amount: document.getElementById('sale_amount').value,
|
|||
|
|
currency: document.getElementById('sale_currency').value || 'DKK',
|
|||
|
|
external_ref: document.getElementById('sale_external_ref').value || null,
|
|||
|
|
purchase_purpose: document.getElementById('sale_purchase_purpose').value || null,
|
|||
|
|
supplier_invoice_id: document.getElementById('sale_supplier_invoice_id').value || null,
|
|||
|
|
supplier_invoice_line_id: document.getElementById('sale_supplier_invoice_line_id').value || null
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (payload.type !== 'purchase') {
|
|||
|
|
payload.purchase_purpose = null;
|
|||
|
|
payload.supplier_invoice_id = null;
|
|||
|
|
payload.supplier_invoice_line_id = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!payload.description || !payload.amount) {
|
|||
|
|
alert('Beskrivelse og linjesum er påkrævet.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const method = itemId ? 'PATCH' : 'POST';
|
|||
|
|
const url = itemId
|
|||
|
|
? `/api/v1/sag/${salesCaseId}/sale-items/${itemId}`
|
|||
|
|
: `/api/v1/sag/${salesCaseId}/sale-items`;
|
|||
|
|
|
|||
|
|
const res = await fetch(url, {
|
|||
|
|
method,
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
alert('Kunne ikke gemme varelinje');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bootstrap.Modal.getInstance(document.getElementById('saleItemModal')).hide();
|
|||
|
|
await loadVarekobSalg();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteSaleItem(itemId) {
|
|||
|
|
if (!confirm('Vil du slette denne varelinje?')) return;
|
|||
|
|
const res = await fetch(`/api/v1/sag/${salesCaseId}/sale-items/${itemId}`, { method: 'DELETE' });
|
|||
|
|
if (!res.ok) {
|
|||
|
|
alert('Kunne ikke slette varelinje');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await loadVarekobSalg();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|||
|
|
const qtyInput = document.getElementById('sale_quantity');
|
|||
|
|
const priceInput = document.getElementById('sale_unit_price');
|
|||
|
|
const typeInput = document.getElementById('sale_type');
|
|||
|
|
if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount);
|
|||
|
|
if (priceInput) priceInput.addEventListener('input', updateSaleAmount);
|
|||
|
|
if (typeInput) typeInput.addEventListener('change', togglePurchaseFields);
|
|||
|
|
loadVarekobSalg();
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const supplierCaseId = {{ case.id }};
|
|||
|
|
|
|||
|
|
function supplierStatusBadge(status) {
|
|||
|
|
const value = String(status || '').toLowerCase();
|
|||
|
|
if (value === 'godkendt') return '<span class="badge bg-primary-subtle text-primary-emphasis">Godkendt</span>';
|
|||
|
|
if (value === 'betalt') return '<span class="badge bg-success-subtle text-success-emphasis">Betalt</span>';
|
|||
|
|
if (value === 'afvist') return '<span class="badge bg-danger-subtle text-danger-emphasis">Afvist</span>';
|
|||
|
|
return '<span class="badge bg-warning-subtle text-warning-emphasis">Modtaget</span>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function inferSupplierFlowSuggestion() {
|
|||
|
|
const purchaseItems = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
|
|||
|
|
if (purchaseItems.length > 0) {
|
|||
|
|
return { type: 'varekob', confidence: 0.87, reason: 'Sag har indkøbslinjer fra leverandørflow' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const purchaseText = (saleItemsCache || [])
|
|||
|
|
.filter((item) => (item.type || '').toLowerCase() === 'purchase')
|
|||
|
|
.map((item) => String(item.description || '').toLowerCase())
|
|||
|
|
.join(' ');
|
|||
|
|
|
|||
|
|
if (/(abonnement|hosting|licens|service|konsulent|rengoering|husleje)/.test(purchaseText)) {
|
|||
|
|
return { type: 'ydelse', confidence: 0.76, reason: 'Beskrivelser matcher ydelsesmønstre' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSupplierSuggestion() {
|
|||
|
|
const suggestionEl = document.getElementById('supplierFlowSuggestion');
|
|||
|
|
if (!suggestionEl) return;
|
|||
|
|
|
|||
|
|
const suggestion = inferSupplierFlowSuggestion();
|
|||
|
|
if (!suggestion) {
|
|||
|
|
suggestionEl.innerHTML = '<span class="text-muted">Ingen sikker AI-indikation endnu</span>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
suggestionEl.innerHTML = `
|
|||
|
|
<div><strong>Forslag:</strong> ${suggestion.type === 'varekob' ? 'Varekøb' : 'Købt ydelse'}</div>
|
|||
|
|
<div class="small text-muted">Confidence: ${(suggestion.confidence * 100).toFixed(0)}% · ${suggestion.reason}</div>
|
|||
|
|
<button class="btn btn-sm btn-outline-primary mt-2" onclick="applySupplierSuggestion('${suggestion.type}', ${suggestion.confidence})">
|
|||
|
|
<i class="bi bi-magic me-1"></i>Anvend forslag
|
|||
|
|
</button>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applySupplierSuggestion(type, confidence) {
|
|||
|
|
const typeEl = document.getElementById('supplierFlowTypeSelect');
|
|||
|
|
const confEl = document.getElementById('supplierFlowConfidenceInput');
|
|||
|
|
if (typeEl) typeEl.value = type;
|
|||
|
|
if (confEl) confEl.value = Number(confidence).toFixed(2);
|
|||
|
|
toggleSupplierTypePanels();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toggleSupplierTypePanels() {
|
|||
|
|
const type = document.getElementById('supplierFlowTypeSelect')?.value || '';
|
|||
|
|
const varekobPanel = document.getElementById('supplierVarekobPanel');
|
|||
|
|
const ydelsePanel = document.getElementById('supplierYdelsePanel');
|
|||
|
|
if (varekobPanel) varekobPanel.style.display = type === 'varekob' ? '' : 'none';
|
|||
|
|
if (ydelsePanel) ydelsePanel.style.display = type === 'ydelse' ? '' : 'none';
|
|||
|
|
if (type === 'varekob') {
|
|||
|
|
renderSupplierPurchasePurposeRows();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveSupplierFlowType() {
|
|||
|
|
const type = document.getElementById('supplierFlowTypeSelect')?.value || null;
|
|||
|
|
const confidenceRaw = document.getElementById('supplierFlowConfidenceInput')?.value || null;
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
supplier_flow_type: type,
|
|||
|
|
supplier_flow_confidence: confidenceRaw === null || confidenceRaw === '' ? null : Number(confidenceRaw)
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/v1/sag/${supplierCaseId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
alert('Kunne ikke gemme leverandørflow type');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
alert('Leverandørflow type gemt');
|
|||
|
|
toggleSupplierTypePanels();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSupplierPurchasePurposeRows() {
|
|||
|
|
const body = document.getElementById('supplierPurchaseLinesBody');
|
|||
|
|
const countBadge = document.getElementById('supplierPurchaseLinesCount');
|
|||
|
|
if (!body) return;
|
|||
|
|
|
|||
|
|
const purchases = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
|
|||
|
|
if (countBadge) countBadge.textContent = `${purchases.length} linjer`;
|
|||
|
|
|
|||
|
|
if (!purchases.length) {
|
|||
|
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen indkøbslinjer fundet på denne sag</td></tr>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body.innerHTML = purchases.map((item) => `
|
|||
|
|
<tr>
|
|||
|
|
<td class="ps-3">${item.description || '-'}</td>
|
|||
|
|
<td>${formatCurrency(item.amount || 0)}</td>
|
|||
|
|
<td>
|
|||
|
|
<select class="form-select form-select-sm" id="supplierPurpose-${item.id}">
|
|||
|
|
<option value="">Vælg...</option>
|
|||
|
|
<option value="salg" ${item.purchase_purpose === 'salg' ? 'selected' : ''}>Videresalg</option>
|
|||
|
|
<option value="asset" ${item.purchase_purpose === 'asset' ? 'selected' : ''}>Opret som Asset</option>
|
|||
|
|
<option value="intern_brug" ${item.purchase_purpose === 'intern_brug' ? 'selected' : ''}>Intern brug</option>
|
|||
|
|
<option value="projekt_omkostning" ${item.purchase_purpose === 'projekt_omkostning' ? 'selected' : ''}>Forbrug/omkostning</option>
|
|||
|
|
<option value="lager" ${item.purchase_purpose === 'lager' ? 'selected' : ''}>Lager</option>
|
|||
|
|
<option value="retur_reklamation" ${item.purchase_purpose === 'retur_reklamation' ? 'selected' : ''}>Retur/reklamation</option>
|
|||
|
|
</select>
|
|||
|
|
</td>
|
|||
|
|
<td class="text-end pe-3">
|
|||
|
|
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierPurchasePurpose(${item.id})">
|
|||
|
|
<i class="bi bi-save"></i>
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveSupplierPurchasePurpose(itemId) {
|
|||
|
|
const purpose = document.getElementById(`supplierPurpose-${itemId}`)?.value || null;
|
|||
|
|
const res = await fetch(`/api/v1/sag/${supplierCaseId}/sale-items/${itemId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
type: 'purchase',
|
|||
|
|
purchase_purpose: purpose
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
alert('Kunne ikke gemme linjehandling');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await loadVarekobSalg();
|
|||
|
|
renderSupplierPurchasePurposeRows();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function registerSupplierServiceHandling(actionCode) {
|
|||
|
|
const serviceType = document.getElementById('supplierServiceType')?.value || '';
|
|||
|
|
const fromDate = document.getElementById('supplierServiceFrom')?.value || '';
|
|||
|
|
const toDate = document.getElementById('supplierServiceTo')?.value || '';
|
|||
|
|
const amount = document.getElementById('supplierServiceAmount')?.value || '';
|
|||
|
|
|
|||
|
|
const content = [
|
|||
|
|
'Supplier ydelse-handling registreret',
|
|||
|
|
`Handling: ${actionCode}`,
|
|||
|
|
`Type: ${serviceType}`,
|
|||
|
|
`Periode: ${fromDate || '-'} -> ${toDate || '-'}`,
|
|||
|
|
`Beløb: ${amount || '-'} DKK`,
|
|||
|
|
].join('\n');
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/v1/sag/${supplierCaseId}/kommentarer`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
indhold: content,
|
|||
|
|
er_system_besked: true,
|
|||
|
|
forfatter: 'Supplier Flow'
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
alert('Kunne ikke registrere ydelseshandling');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
alert('Ydelseshandling registreret på sagen');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function supplierApproveInvoice(invoiceId) {
|
|||
|
|
const approvedBy = prompt('Hvem godkender?', 'System');
|
|||
|
|
if (!approvedBy) return;
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ approved_by: approvedBy })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const msg = await res.text();
|
|||
|
|
alert(`Kunne ikke godkende faktura: ${msg}`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await loadSupplierModule();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function supplierRejectInvoice(invoiceId) {
|
|||
|
|
const rejectedBy = prompt('Hvem afviser?', 'System');
|
|||
|
|
if (!rejectedBy) return;
|
|||
|
|
const reason = prompt('Årsag til afvisning (valgfri):', '') || null;
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/reject`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ rejected_by: rejectedBy, reason })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const msg = await res.text();
|
|||
|
|
alert(`Kunne ikke afvise faktura: ${msg}`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await loadSupplierModule();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function supplierMarkPaid(invoiceId) {
|
|||
|
|
const amountRaw = prompt('Betalingsbeløb (tom = restbeløb):', '');
|
|||
|
|
const amount = amountRaw && amountRaw.trim() !== '' ? Number(amountRaw) : null;
|
|||
|
|
if (amountRaw && (!Number.isFinite(amount) || amount <= 0)) {
|
|||
|
|
alert('Ugyldigt beløb');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const method = prompt('Betalingsmetode (fx bank):', '') || null;
|
|||
|
|
const reference = prompt('Betalingsreference (valgfri):', '') || null;
|
|||
|
|
const paidBy = prompt('Registreret af:', 'System') || 'System';
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
amount,
|
|||
|
|
payment_method: method,
|
|||
|
|
payment_reference: reference,
|
|||
|
|
paid_by: paidBy
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const msg = await res.text();
|
|||
|
|
alert(`Kunne ikke registrere betaling: ${msg}`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await loadSupplierModule();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSupplierInvoices(items) {
|
|||
|
|
const body = document.getElementById('supplierInvoicesBody');
|
|||
|
|
if (!body) return;
|
|||
|
|
|
|||
|
|
if (!items || !items.length) {
|
|||
|
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen leverandørfakturaer på sagen</td></tr>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body.innerHTML = items.map((inv) => {
|
|||
|
|
const statusV2 = inv.status_v2 || 'modtaget';
|
|||
|
|
return `
|
|||
|
|
<tr>
|
|||
|
|
<td class="ps-3">
|
|||
|
|
<div class="fw-semibold">${inv.invoice_number || `ID-${inv.id}`}</div>
|
|||
|
|
<div class="small text-muted">${inv.invoice_date || '-'}</div>
|
|||
|
|
</td>
|
|||
|
|
<td>${formatCurrency(inv.total_amount || 0)}</td>
|
|||
|
|
<td>${supplierStatusBadge(statusV2)}</td>
|
|||
|
|
<td class="text-end pe-3">
|
|||
|
|
<div class="btn-group btn-group-sm">
|
|||
|
|
<button class="btn btn-outline-primary" onclick="supplierApproveInvoice(${inv.id})" title="Godkend"><i class="bi bi-check2"></i></button>
|
|||
|
|
<button class="btn btn-outline-danger" onclick="supplierRejectInvoice(${inv.id})" title="Afvis"><i class="bi bi-x"></i></button>
|
|||
|
|
<button class="btn btn-outline-success" onclick="supplierMarkPaid(${inv.id})" title="Registrer betaling"><i class="bi bi-cash"></i></button>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadSupplierModule() {
|
|||
|
|
const body = document.getElementById('supplierInvoicesBody');
|
|||
|
|
if (body) {
|
|||
|
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/supplier-invoices?sag_id=${supplierCaseId}`);
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke hente leverandørfakturaer');
|
|||
|
|
const items = await res.json();
|
|||
|
|
renderSupplierInvoices(items || []);
|
|||
|
|
renderSupplierSuggestion();
|
|||
|
|
toggleSupplierTypePanels();
|
|||
|
|
setModuleContentState('supplier', (items || []).length > 0);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(error);
|
|||
|
|
if (body) {
|
|||
|
|
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Kunne ikke hente leverandørfakturaer</td></tr>';
|
|||
|
|
}
|
|||
|
|
setModuleContentState('supplier', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|||
|
|
loadSupplierModule();
|
|||
|
|
toggleSupplierTypePanels();
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const timeCaseId = {{ case.id }};
|
|||
|
|
let timeV1EntriesById = {};
|
|||
|
|
|
|||
|
|
function minutesToLabel(minutes) {
|
|||
|
|
const value = Number(minutes || 0);
|
|||
|
|
const h = Math.floor(value / 60);
|
|||
|
|
const m = value % 60;
|
|||
|
|
return `${h}t ${m}m`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function timeStatusBadge(status) {
|
|||
|
|
if (status === 'godkendt') return '<span class="badge bg-success">Godkendt</span>';
|
|||
|
|
if (status === 'kladde') return '<span class="badge bg-secondary">Kladde</span>';
|
|||
|
|
return '<span class="badge bg-warning text-dark">Afventer</span>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTimeV1Timeline(entries) {
|
|||
|
|
const timeline = document.getElementById('timeTimelineColumns');
|
|||
|
|
if (!timeline) return;
|
|||
|
|
|
|||
|
|
if (!entries || entries.length === 0) {
|
|||
|
|
timeline.innerHTML = '<div class="text-muted text-center p-4">Ingen tidsregistreringer endnu</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const START_HOUR = 7;
|
|||
|
|
const TOTAL_HOURS = 10;
|
|||
|
|
const HOUR_HEIGHT = 60;
|
|||
|
|
|
|||
|
|
const PALETTE = [
|
|||
|
|
{ border: '#0f4c75', bg: 'rgba(15,76,117,0.09)', header: 'rgba(15,76,117,0.08)' },
|
|||
|
|
{ border: '#ef4444', bg: 'rgba(239,68,68,0.09)', header: 'rgba(239,68,68,0.08)' },
|
|||
|
|
{ border: '#10b981', bg: 'rgba(16,185,129,0.09)', header: 'rgba(16,185,129,0.08)' },
|
|||
|
|
{ border: '#f59e0b', bg: 'rgba(245,158,11,0.09)', header: 'rgba(245,158,11,0.08)' },
|
|||
|
|
{ border: '#8b5cf6', bg: 'rgba(139,92,246,0.09)', header: 'rgba(139,92,246,0.08)' },
|
|||
|
|
{ border: '#ec4899', bg: 'rgba(236,72,153,0.09)', header: 'rgba(236,72,153,0.08)' },
|
|||
|
|
{ border: '#06b6d4', bg: 'rgba(6,182,212,0.09)', header: 'rgba(6,182,212,0.08)' },
|
|||
|
|
{ border: '#f97316', bg: 'rgba(249,115,22,0.09)', header: 'rgba(249,115,22,0.08)' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const allUsers = [...new Set(entries.map(e => e.employee_display_name || e.bruger_navn || e.user_name || 'Ukendt'))].sort();
|
|||
|
|
const userColor = {};
|
|||
|
|
allUsers.forEach((u, i) => { userColor[u] = PALETTE[i % PALETTE.length]; });
|
|||
|
|
|
|||
|
|
const groupedByDate = {};
|
|||
|
|
entries.forEach(entry => {
|
|||
|
|
let dateKey;
|
|||
|
|
if (entry.start_tid) dateKey = entry.start_tid.substring(0, 10);
|
|||
|
|
else if (entry.worked_date) dateKey = entry.worked_date.substring(0, 10);
|
|||
|
|
else if (entry.created_at) dateKey = entry.created_at.substring(0, 10);
|
|||
|
|
else dateKey = 'Ukendt dato';
|
|||
|
|
if (!groupedByDate[dateKey]) groupedByDate[dateKey] = [];
|
|||
|
|
groupedByDate[dateKey].push(entry);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sortedDates = Object.keys(groupedByDate).sort((a, b) => new Date(b) - new Date(a));
|
|||
|
|
let html = '';
|
|||
|
|
|
|||
|
|
sortedDates.forEach(dateStr => {
|
|||
|
|
const dayEntries = groupedByDate[dateStr];
|
|||
|
|
let dateLab = dateStr;
|
|||
|
|
try {
|
|||
|
|
const d = new Date(dateStr);
|
|||
|
|
if (!isNaN(d.getTime())) {
|
|||
|
|
dateLab = d.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
|||
|
|
dateLab = dateLab.charAt(0).toUpperCase() + dateLab.slice(1);
|
|||
|
|
}
|
|||
|
|
} catch(e) {}
|
|||
|
|
|
|||
|
|
const techPlaced = {};
|
|||
|
|
const unplaced = [];
|
|||
|
|
const userTotals = {};
|
|||
|
|
const techMeta = {};
|
|||
|
|
|
|||
|
|
dayEntries.forEach(entry => {
|
|||
|
|
const tech = entry.employee_display_name || entry.bruger_navn || entry.user_name || 'Ukendt';
|
|||
|
|
const mins = entry.faktisk_tid_min
|
|||
|
|
? parseInt(entry.faktisk_tid_min)
|
|||
|
|
: Math.round(parseFloat(entry.original_hours || entry.timer || 0) * 60);
|
|||
|
|
userTotals[tech] = (userTotals[tech] || 0) + mins;
|
|||
|
|
if (!techMeta[tech]) {
|
|||
|
|
techMeta[tech] = {
|
|||
|
|
medarbejderId: entry.medarbejder_id || null,
|
|||
|
|
userName: entry.user_name || null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
if (entry.start_tid) {
|
|||
|
|
if (!techPlaced[tech]) techPlaced[tech] = [];
|
|||
|
|
techPlaced[tech].push(entry);
|
|||
|
|
} else {
|
|||
|
|
unplaced.push(entry);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const techNames = Object.keys(userTotals).sort();
|
|||
|
|
|
|||
|
|
html += `<div class="time-v1-calendar-container">
|
|||
|
|
<div class="time-v1-calendar-header">
|
|||
|
|
<i class="bi bi-calendar3 text-primary"></i> ${dateLab}
|
|||
|
|
</div>
|
|||
|
|
<div class="time-v1-calendar-grid">
|
|||
|
|
<div class="time-v1-time-axis">`;
|
|||
|
|
|
|||
|
|
for (let i = 0; i <= TOTAL_HOURS; i++) {
|
|||
|
|
const h = START_HOUR + i;
|
|||
|
|
html += `<div class="time-v1-hour-marker" style="top:${i * HOUR_HEIGHT}px">${String(h).padStart(2,'0')}:00</div>`;
|
|||
|
|
}
|
|||
|
|
html += `</div>`;
|
|||
|
|
|
|||
|
|
techNames.forEach(tech => {
|
|||
|
|
const c = userColor[tech] || PALETTE[0];
|
|||
|
|
const tot = userTotals[tech] || 0;
|
|||
|
|
const techInfo = techMeta[tech] || {};
|
|||
|
|
const techEmployeeId = techInfo.medarbejderId != null ? String(techInfo.medarbejderId) : '';
|
|||
|
|
const totS = tot >= 60
|
|||
|
|
? `${Math.floor(tot/60)}t${tot%60 ? ' '+tot%60+'m' : ''}`
|
|||
|
|
: `${tot}m`;
|
|||
|
|
|
|||
|
|
html += `<div class="time-v1-tech-col" style="border-top:3px solid ${c.border};">
|
|||
|
|
<div class="time-v1-tech-header" style="background:${c.header};">
|
|||
|
|
<i class="bi bi-person-fill" style="color:${c.border};"></i>
|
|||
|
|
<span style="color:${c.border};font-weight:600;">${escapeHtml(tech)}</span>
|
|||
|
|
<span class="ms-auto badge" style="background:${c.border};color:#fff;font-size:0.7rem;">${totS}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="time-v1-tech-body" data-date="${dateStr}" data-tech="${escapeHtml(tech)}" data-medarbejder-id="${techEmployeeId}">`;
|
|||
|
|
|
|||
|
|
const posEntries = [];
|
|||
|
|
(techPlaced[tech] || []).forEach(entry => {
|
|||
|
|
const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
|
|||
|
|
const startObj = new Date(entry.start_tid);
|
|||
|
|
let durMin = 30;
|
|||
|
|
if (entry.faktisk_tid_min) durMin = parseInt(entry.faktisk_tid_min);
|
|||
|
|
else if (entry.original_hours || entry.timer) durMin = Math.round(parseFloat(entry.original_hours || entry.timer) * 60);
|
|||
|
|
|
|||
|
|
let sH = startObj.getHours(), sM = startObj.getMinutes();
|
|||
|
|
if (sH < START_HOUR) { durMin -= (START_HOUR * 60 - sH * 60 - sM); sH = START_HOUR; sM = 0; }
|
|||
|
|
|
|||
|
|
let topPx = ((sH - START_HOUR) + sM / 60) * HOUR_HEIGHT;
|
|||
|
|
let heightPx = (durMin / 60) * HOUR_HEIGHT;
|
|||
|
|
if (topPx < 0) topPx = 0;
|
|||
|
|
if (topPx + heightPx > TOTAL_HOURS * HOUR_HEIGHT) heightPx = TOTAL_HOURS * HOUR_HEIGHT - topPx;
|
|||
|
|
|
|||
|
|
if (heightPx > 5 && topPx < TOTAL_HOURS * HOUR_HEIGHT) {
|
|||
|
|
const endObj = new Date(startObj.getTime() + durMin * 60000);
|
|||
|
|
const timeStr = `${String(startObj.getHours()).padStart(2,'0')}:${String(startObj.getMinutes()).padStart(2,'0')} \u2013 ${String(endObj.getHours()).padStart(2,'0')}:${String(endObj.getMinutes()).padStart(2,'0')}`;
|
|||
|
|
posEntries.push({
|
|||
|
|
topPx,
|
|||
|
|
heightPx,
|
|||
|
|
desc,
|
|||
|
|
timeStr,
|
|||
|
|
startMin: topPx,
|
|||
|
|
endMin: topPx + heightPx,
|
|||
|
|
entryId: entry.id,
|
|||
|
|
durationMin: Math.max(1, durMin || 0),
|
|||
|
|
entryStatus: entry.entry_status || 'afventer',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
posEntries.sort((a, b) => a.startMin - b.startMin);
|
|||
|
|
const lanes = [];
|
|||
|
|
posEntries.forEach(e => {
|
|||
|
|
let placed = false;
|
|||
|
|
for (let li = 0; li < lanes.length; li++) {
|
|||
|
|
if (lanes[li] <= e.startMin) { e.lane = li; lanes[li] = e.endMin; placed = true; break; }
|
|||
|
|
}
|
|||
|
|
if (!placed) { e.lane = lanes.length; lanes.push(e.endMin); }
|
|||
|
|
});
|
|||
|
|
const numLanes = lanes.length || 1;
|
|||
|
|
|
|||
|
|
posEntries.forEach(e => {
|
|||
|
|
e.laneSpan = 1;
|
|||
|
|
for (let li = e.lane + 1; li < numLanes; li++) {
|
|||
|
|
if (!posEntries.some(o => o !== e && o.lane === li && o.startMin < e.endMin && o.endMin > e.startMin)) e.laneSpan++;
|
|||
|
|
else break;
|
|||
|
|
}
|
|||
|
|
const lW = 100 / numLanes;
|
|||
|
|
html += `<div class="time-v1-entry-block time-v1-entry-${e.entryStatus}"
|
|||
|
|
draggable="true"
|
|||
|
|
data-entry-id="${e.entryId}"
|
|||
|
|
data-duration-min="${e.durationMin}"
|
|||
|
|
style="top:${e.topPx}px;height:${e.heightPx}px;left:${(e.lane*lW).toFixed(1)}%;width:calc(${(e.laneSpan*lW).toFixed(1)}% - 3px);border-left-color:${c.border};background:${c.bg};"
|
|||
|
|
title="Dobbeltklik for detaljeret redigering\n${e.desc}">
|
|||
|
|
<div class="time-v1-entry-time">${e.timeStr}</div>
|
|||
|
|
<div class="time-v1-entry-desc">${e.desc}</div>
|
|||
|
|
</div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
html += `</div></div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
html += `</div>`;
|
|||
|
|
|
|||
|
|
if (unplaced.length > 0) {
|
|||
|
|
html += `<div class="time-v1-unplaced-container">
|
|||
|
|
<span class="text-muted small fw-semibold me-2"><i class="bi bi-clock-history"></i> Uden tidsrum:</span>`;
|
|||
|
|
unplaced.forEach(u => {
|
|||
|
|
const tech = u.employee_display_name || u.bruger_navn || u.user_name || 'Ukendt';
|
|||
|
|
const c = userColor[tech] || PALETTE[0];
|
|||
|
|
const mins = u.faktisk_tid_min ? parseInt(u.faktisk_tid_min) : Math.round(parseFloat(u.original_hours || u.timer || 0) * 60);
|
|||
|
|
const hStr = mins >= 60 ? `${Math.floor(mins/60)}t${mins%60?' '+mins%60+'m':''}` : `${mins}m`;
|
|||
|
|
const desc = escapeHtml(u.beskrivelse || u.description || '');
|
|||
|
|
html += `<div class="time-v1-unplaced-item"
|
|||
|
|
draggable="true"
|
|||
|
|
data-entry-id="${u.id}"
|
|||
|
|
data-duration-min="${Math.max(1, mins || 0)}"
|
|||
|
|
title="Dobbeltklik for detaljeret redigering"
|
|||
|
|
style="border-color:${c.border};color:${c.border};">
|
|||
|
|
<i class="bi bi-person-fill"></i> ${escapeHtml(tech)} • ${hStr}${desc ? ' · <em style="opacity:.7;font-size:.72rem">'+desc+'</em>' : ''}
|
|||
|
|
</div>`;
|
|||
|
|
});
|
|||
|
|
html += `</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
html += `</div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
timeline.innerHTML = html;
|
|||
|
|
initTimeV1DragAndDrop();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTimeV1Summary(entries) {
|
|||
|
|
const summaryEl = document.getElementById('timeSummaryEntries');
|
|||
|
|
if (!summaryEl) return;
|
|||
|
|
|
|||
|
|
if (!entries || entries.length === 0) {
|
|||
|
|
summaryEl.innerHTML = '<div class="text-muted small">Ingen tidsregistreringer på sagen endnu.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const statusLabel = (status) => {
|
|||
|
|
if (status === 'godkendt') return 'Godkendt';
|
|||
|
|
if (status === 'kladde') return 'Kladde';
|
|||
|
|
return 'Afventer';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const typeLabel = (type) => {
|
|||
|
|
const t = String(type || 'ukendt').toLowerCase();
|
|||
|
|
if (t === 'opkald') return 'Opkald';
|
|||
|
|
if (t === 'mail') return 'Mail';
|
|||
|
|
if (t === 'anydesk' || t === 'indedesk') return 'AnyDesk';
|
|||
|
|
if (t === 'manuel') return 'Manuel';
|
|||
|
|
return 'Ukendt';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sorted = [...entries].sort((a, b) => {
|
|||
|
|
const aKey = a.start_tid || a.worked_date || a.created_at || '';
|
|||
|
|
const bKey = b.start_tid || b.worked_date || b.created_at || '';
|
|||
|
|
return String(bKey).localeCompare(String(aKey));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let totalActualMin = 0;
|
|||
|
|
let totalBillableMin = 0;
|
|||
|
|
const byStatus = { kladde: 0, afventer: 0, godkendt: 0 };
|
|||
|
|
const byType = {};
|
|||
|
|
|
|||
|
|
sorted.forEach((entry) => {
|
|||
|
|
const actual = entry.faktisk_tid_min != null
|
|||
|
|
? Number(entry.faktisk_tid_min)
|
|||
|
|
: Math.round(Number(entry.original_hours || entry.timer || 0) * 60);
|
|||
|
|
const billable = entry.fakturerbar_tid_min != null
|
|||
|
|
? Number(entry.fakturerbar_tid_min)
|
|||
|
|
: actual;
|
|||
|
|
|
|||
|
|
totalActualMin += Number.isFinite(actual) ? actual : 0;
|
|||
|
|
totalBillableMin += Number.isFinite(billable) ? billable : 0;
|
|||
|
|
|
|||
|
|
const st = String(entry.entry_status || 'afventer').toLowerCase();
|
|||
|
|
if (Object.prototype.hasOwnProperty.call(byStatus, st)) {
|
|||
|
|
byStatus[st] += 1;
|
|||
|
|
} else {
|
|||
|
|
byStatus.afventer += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tp = String(entry.entry_type || 'ukendt').toLowerCase();
|
|||
|
|
byType[tp] = (byType[tp] || 0) + 1;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const typeRows = Object.entries(byType)
|
|||
|
|
.sort((a, b) => b[1] - a[1])
|
|||
|
|
.map(([key, count]) => `<span class="badge bg-light text-dark border">${escapeHtml(typeLabel(key))}: ${count}</span>`)
|
|||
|
|
.join(' ');
|
|||
|
|
|
|||
|
|
const detailRows = sorted.map((entry) => {
|
|||
|
|
const tech = entry.employee_display_name || entry.bruger_navn || entry.user_name || 'Ukendt';
|
|||
|
|
const actual = entry.faktisk_tid_min != null
|
|||
|
|
? Number(entry.faktisk_tid_min)
|
|||
|
|
: Math.round(Number(entry.original_hours || entry.timer || 0) * 60);
|
|||
|
|
const dateValue = entry.worked_date || (entry.start_tid ? String(entry.start_tid).slice(0, 10) : null) || '-';
|
|||
|
|
|
|||
|
|
let timeLabel = 'Uden tidsrum';
|
|||
|
|
if (entry.start_tid) {
|
|||
|
|
const start = new Date(entry.start_tid);
|
|||
|
|
if (!Number.isNaN(start.getTime())) {
|
|||
|
|
if (entry.slut_tid) {
|
|||
|
|
const end = new Date(entry.slut_tid);
|
|||
|
|
if (!Number.isNaN(end.getTime())) {
|
|||
|
|
timeLabel = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')} - ${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`;
|
|||
|
|
} else {
|
|||
|
|
timeLabel = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
timeLabel = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const desc = escapeHtml(entry.beskrivelse || entry.description || 'Ingen beskrivelse');
|
|||
|
|
return `
|
|||
|
|
<div class="border rounded p-2 mb-2" data-time-summary-entry-id="${entry.id}">
|
|||
|
|
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
|
|||
|
|
<div class="small fw-semibold">${escapeHtml(tech)}</div>
|
|||
|
|
<span class="badge bg-light text-dark border">${statusLabel(entry.entry_status)}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="small text-muted">${escapeHtml(dateValue)} • ${escapeHtml(timeLabel)} • ${minutesToLabel(actual)}</div>
|
|||
|
|
<div class="small text-muted">Type: ${escapeHtml(typeLabel(entry.entry_type))}</div>
|
|||
|
|
<div class="small mt-1">${desc}</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
summaryEl.innerHTML = `
|
|||
|
|
<div class="small mb-3">
|
|||
|
|
<div class="d-flex justify-content-between"><span class="text-muted">Registreringer</span><strong>${sorted.length}</strong></div>
|
|||
|
|
<div class="d-flex justify-content-between"><span class="text-muted">Faktisk tid</span><strong>${minutesToLabel(totalActualMin)}</strong></div>
|
|||
|
|
<div class="d-flex justify-content-between"><span class="text-muted">Fakturerbar tid</span><strong>${minutesToLabel(totalBillableMin)}</strong></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="small mb-2 text-muted">Status</div>
|
|||
|
|
<div class="d-flex flex-wrap gap-1 mb-3">
|
|||
|
|
<span class="badge bg-light text-dark border">Godkendt: ${byStatus.godkendt}</span>
|
|||
|
|
<span class="badge bg-light text-dark border">Afventer: ${byStatus.afventer}</span>
|
|||
|
|
<span class="badge bg-light text-dark border">Kladde: ${byStatus.kladde}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="small mb-2 text-muted">Typer</div>
|
|||
|
|
<div class="d-flex flex-wrap gap-1 mb-3">${typeRows || '<span class="badge bg-light text-dark border">Ingen</span>'}</div>
|
|||
|
|
|
|||
|
|
<div class="small mb-2 text-muted">Detaljer (alle registreringer)</div>
|
|||
|
|
<div style="max-height: 54vh; overflow:auto;">
|
|||
|
|
${detailRows}
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let timeV1DragBusy = false;
|
|||
|
|
|
|||
|
|
function parseTimeV1DragPayload(event) {
|
|||
|
|
const raw = event.dataTransfer?.getData('text/plain');
|
|||
|
|
if (!raw) return null;
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(raw);
|
|||
|
|
if (!parsed || !parsed.entryId) return null;
|
|||
|
|
return {
|
|||
|
|
entryId: Number(parsed.entryId),
|
|||
|
|
durationMin: Math.max(1, Number(parsed.durationMin || 30)),
|
|||
|
|
};
|
|||
|
|
} catch (e) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toIsoLocalFromDateMinutes(dateStr, totalMinutes) {
|
|||
|
|
const mins = Math.max(0, Math.floor(totalMinutes));
|
|||
|
|
const hh = String(Math.floor(mins / 60)).padStart(2, '0');
|
|||
|
|
const mm = String(mins % 60).padStart(2, '0');
|
|||
|
|
return `${dateStr}T${hh}:${mm}:00`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function patchTimeV1EntryPlacement(entryId, payload) {
|
|||
|
|
const res = await fetch(`/api/v1/timetracking/time/${entryId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let errorText = `${res.status}`;
|
|||
|
|
try {
|
|||
|
|
const body = await res.json();
|
|||
|
|
errorText = body?.detail || JSON.stringify(body);
|
|||
|
|
} catch (e) {
|
|||
|
|
errorText = await res.text();
|
|||
|
|
}
|
|||
|
|
throw new Error(errorText || 'Kunne ikke gemme tidsplacering');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initTimeV1DragAndDrop() {
|
|||
|
|
const timeline = document.getElementById('timeTimelineColumns');
|
|||
|
|
if (!timeline) return;
|
|||
|
|
|
|||
|
|
const draggables = timeline.querySelectorAll('.time-v1-unplaced-item[draggable="true"], .time-v1-entry-block[draggable="true"]');
|
|||
|
|
draggables.forEach((el) => {
|
|||
|
|
el.addEventListener('dragstart', (event) => {
|
|||
|
|
const payload = {
|
|||
|
|
entryId: Number(el.dataset.entryId || 0),
|
|||
|
|
durationMin: Math.max(1, Number(el.dataset.durationMin || 30)),
|
|||
|
|
};
|
|||
|
|
if (!payload.entryId) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
event.dataTransfer.setData('text/plain', JSON.stringify(payload));
|
|||
|
|
event.dataTransfer.effectAllowed = 'move';
|
|||
|
|
el.classList.add('dragging');
|
|||
|
|
});
|
|||
|
|
el.addEventListener('dblclick', (event) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
event.stopPropagation();
|
|||
|
|
const entryId = Number(el.dataset.entryId || 0);
|
|||
|
|
if (entryId > 0) {
|
|||
|
|
openTimeV1DetailedEdit(entryId);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
el.addEventListener('dragend', () => {
|
|||
|
|
el.classList.remove('dragging');
|
|||
|
|
timeline.querySelectorAll('.time-v1-tech-body.time-v1-drop-target').forEach((target) => target.classList.remove('time-v1-drop-target'));
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const START_HOUR = 7;
|
|||
|
|
const TOTAL_HOURS = 10;
|
|||
|
|
const SNAP_MIN = 15;
|
|||
|
|
|
|||
|
|
const dropTargets = timeline.querySelectorAll('.time-v1-tech-body[data-date]');
|
|||
|
|
dropTargets.forEach((target) => {
|
|||
|
|
target.addEventListener('dragenter', (event) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
target.classList.add('time-v1-drop-target');
|
|||
|
|
});
|
|||
|
|
target.addEventListener('dragover', (event) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
event.dataTransfer.dropEffect = 'move';
|
|||
|
|
target.classList.add('time-v1-drop-target');
|
|||
|
|
});
|
|||
|
|
target.addEventListener('dragleave', (event) => {
|
|||
|
|
if (!target.contains(event.relatedTarget)) {
|
|||
|
|
target.classList.remove('time-v1-drop-target');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
target.addEventListener('drop', async (event) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
target.classList.remove('time-v1-drop-target');
|
|||
|
|
|
|||
|
|
if (timeV1DragBusy) return;
|
|||
|
|
const drag = parseTimeV1DragPayload(event);
|
|||
|
|
if (!drag || !drag.entryId) return;
|
|||
|
|
|
|||
|
|
const dateStr = target.dataset.date;
|
|||
|
|
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|||
|
|
alert('Kunne ikke bestemme dato for drop-felt');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rect = target.getBoundingClientRect();
|
|||
|
|
const y = Math.max(0, Math.min(rect.height, event.clientY - rect.top));
|
|||
|
|
const minutesFromTop = Math.round((y / rect.height) * (TOTAL_HOURS * 60));
|
|||
|
|
const snapped = Math.round(minutesFromTop / SNAP_MIN) * SNAP_MIN;
|
|||
|
|
const duration = Math.max(1, Number(drag.durationMin || 30));
|
|||
|
|
const maxStart = Math.max(0, TOTAL_HOURS * 60 - duration);
|
|||
|
|
const clamped = Math.max(0, Math.min(snapped, maxStart));
|
|||
|
|
|
|||
|
|
const startMinutes = (START_HOUR * 60) + clamped;
|
|||
|
|
const endMinutes = startMinutes + duration;
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
start_tid: toIsoLocalFromDateMinutes(dateStr, startMinutes),
|
|||
|
|
slut_tid: toIsoLocalFromDateMinutes(dateStr, endMinutes),
|
|||
|
|
worked_date: dateStr,
|
|||
|
|
ikke_placeret: false,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const medarbejderIdRaw = target.dataset.medarbejderId;
|
|||
|
|
if (medarbejderIdRaw !== undefined && medarbejderIdRaw !== null && medarbejderIdRaw !== '') {
|
|||
|
|
const medarbejderId = Number(medarbejderIdRaw);
|
|||
|
|
if (!Number.isNaN(medarbejderId) && medarbejderId > 0) {
|
|||
|
|
payload.medarbejder_id = medarbejderId;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
timeV1DragBusy = true;
|
|||
|
|
try {
|
|||
|
|
await patchTimeV1EntryPlacement(drag.entryId, payload);
|
|||
|
|
await loadTimeTrackingTab();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Kunne ikke flytte tidsregistrering: ' + (error.message || 'ukendt fejl'));
|
|||
|
|
} finally {
|
|||
|
|
timeV1DragBusy = false;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureTimeV1EditEmployeeOptions() {
|
|||
|
|
const source = document.getElementById('timeV1EmployeeId');
|
|||
|
|
const target = document.getElementById('timeV1EditEmployeeId');
|
|||
|
|
if (!source || !target) return;
|
|||
|
|
if (target.dataset.initialized === '1') return;
|
|||
|
|
target.innerHTML = source.innerHTML;
|
|||
|
|
target.dataset.initialized = '1';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toLocalDateInputValue(value) {
|
|||
|
|
if (!value) return '';
|
|||
|
|
const d = new Date(value);
|
|||
|
|
if (Number.isNaN(d.getTime())) return '';
|
|||
|
|
const y = d.getFullYear();
|
|||
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|||
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|||
|
|
return `${y}-${m}-${day}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toLocalTimeInputValue(value) {
|
|||
|
|
if (!value) return '';
|
|||
|
|
const d = new Date(value);
|
|||
|
|
if (Number.isNaN(d.getTime())) return '';
|
|||
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openTimeV1DetailedEdit(entryId) {
|
|||
|
|
const entry = timeV1EntriesById[Number(entryId)];
|
|||
|
|
if (!entry) {
|
|||
|
|
alert('Kunne ikke finde tidsregistreringen');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ensureTimeV1EditEmployeeOptions();
|
|||
|
|
|
|||
|
|
const minutes = entry.faktisk_tid_min
|
|||
|
|
? Number(entry.faktisk_tid_min)
|
|||
|
|
: Math.max(1, Math.round(Number(entry.original_hours || entry.timer || 0) * 60));
|
|||
|
|
|
|||
|
|
document.getElementById('timeV1EditId').value = String(entry.id || '');
|
|||
|
|
document.getElementById('timeV1EditDate').value = entry.worked_date || toLocalDateInputValue(entry.start_tid) || toLocalDateInputValue(entry.created_at);
|
|||
|
|
document.getElementById('timeV1EditStart').value = toLocalTimeInputValue(entry.start_tid);
|
|||
|
|
document.getElementById('timeV1EditEnd').value = toLocalTimeInputValue(entry.slut_tid);
|
|||
|
|
document.getElementById('timeV1EditMinutes').value = String(minutes || 1);
|
|||
|
|
document.getElementById('timeV1EditDescription').value = entry.beskrivelse || entry.description || '';
|
|||
|
|
document.getElementById('timeV1EditType').value = entry.entry_type || 'manuel';
|
|||
|
|
document.getElementById('timeV1EditStatus').value = entry.entry_status || 'afventer';
|
|||
|
|
document.getElementById('timeV1EditBillable').checked = Boolean(entry.billable !== false);
|
|||
|
|
document.getElementById('timeV1EditEmployeeId').value = entry.medarbejder_id != null ? String(entry.medarbejder_id) : '';
|
|||
|
|
|
|||
|
|
const modalEl = document.getElementById('timeV1EditModal');
|
|||
|
|
if (!modalEl) return;
|
|||
|
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveTimeV1DetailedEdit() {
|
|||
|
|
const entryId = Number(document.getElementById('timeV1EditId')?.value || 0);
|
|||
|
|
if (!entryId) {
|
|||
|
|
alert('Manglende entry-id');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dateVal = document.getElementById('timeV1EditDate')?.value || null;
|
|||
|
|
const startVal = document.getElementById('timeV1EditStart')?.value || '';
|
|||
|
|
const endVal = document.getElementById('timeV1EditEnd')?.value || '';
|
|||
|
|
const minutes = Math.max(1, Number(document.getElementById('timeV1EditMinutes')?.value || 0));
|
|||
|
|
|
|||
|
|
let startIso = null;
|
|||
|
|
let endIso = null;
|
|||
|
|
if (dateVal && startVal) {
|
|||
|
|
startIso = `${dateVal}T${startVal}:00`;
|
|||
|
|
if (endVal) {
|
|||
|
|
endIso = `${dateVal}T${endVal}:00`;
|
|||
|
|
} else {
|
|||
|
|
const startDate = new Date(startIso);
|
|||
|
|
if (!Number.isNaN(startDate.getTime())) {
|
|||
|
|
endIso = new Date(startDate.getTime() + (minutes * 60000)).toISOString();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rawEmployee = document.getElementById('timeV1EditEmployeeId')?.value || '';
|
|||
|
|
const parsedEmployee = rawEmployee ? Number(rawEmployee) : null;
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
worked_date: dateVal,
|
|||
|
|
description: document.getElementById('timeV1EditDescription')?.value || null,
|
|||
|
|
entry_type: document.getElementById('timeV1EditType')?.value || 'manuel',
|
|||
|
|
entry_status: document.getElementById('timeV1EditStatus')?.value || 'afventer',
|
|||
|
|
billable: Boolean(document.getElementById('timeV1EditBillable')?.checked),
|
|||
|
|
medarbejder_id: Number.isNaN(parsedEmployee) ? null : parsedEmployee,
|
|||
|
|
start_tid: startIso,
|
|||
|
|
slut_tid: endIso,
|
|||
|
|
ikke_placeret: !startIso,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (!startIso) {
|
|||
|
|
payload.slut_tid = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await patchTimeV1EntryPlacement(entryId, payload);
|
|||
|
|
const modalEl = document.getElementById('timeV1EditModal');
|
|||
|
|
if (modalEl) {
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(modalEl).hide();
|
|||
|
|
}
|
|||
|
|
await loadTimeTrackingTab();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Kunne ikke gemme detalje-redigering: ' + (error.message || 'ukendt fejl'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadTimeTrackingTab() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/timetracking/time?sag_id=${timeCaseId}`);
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke hente tidsforbrug');
|
|||
|
|
const entries = await res.json();
|
|||
|
|
timeV1EntriesById = Object.fromEntries((entries || []).map((entry) => [Number(entry.id), entry]));
|
|||
|
|
renderTimeV1Timeline(entries || []);
|
|||
|
|
renderTimeV1Summary(entries || []);
|
|||
|
|
setModuleContentState('timetracking', (entries || []).length > 0);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(error);
|
|||
|
|
const timeline = document.getElementById('timeTimelineColumns');
|
|||
|
|
const summary = document.getElementById('timeSummaryEntries');
|
|||
|
|
if (timeline) {
|
|||
|
|
timeline.innerHTML = '<div class="text-danger text-center py-3">Kunne ikke hente tidsforbrug.</div>';
|
|||
|
|
}
|
|||
|
|
if (summary) {
|
|||
|
|
summary.innerHTML = '<div class="text-danger small">Kunne ikke hente tids-sammendrag.</div>';
|
|||
|
|
}
|
|||
|
|
setModuleContentState('timetracking', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function startLiveTimerV1() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/timetracking/time/start', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
sag_id: timeCaseId,
|
|||
|
|
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
|
|||
|
|
beskrivelse: document.getElementById('timeV1Description')?.value || null
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (!res.ok) throw new Error(await res.text());
|
|||
|
|
await loadTimeTrackingTab();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Kunne ikke starte timer: ' + (error.message || 'ukendt fejl'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function stopLiveTimerV1(extra = {}) {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/timetracking/time/stop', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(extra || {})
|
|||
|
|
});
|
|||
|
|
if (!res.ok) throw new Error(await res.text());
|
|||
|
|
await loadTimeTrackingTab();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Kunne ikke stoppe timer: ' + (error.message || 'ukendt fejl'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bindTimeV1Calculations() {
|
|||
|
|
const startIn = document.getElementById('timeV1Start');
|
|||
|
|
const endIn = document.getElementById('timeV1End');
|
|||
|
|
const minIn = document.getElementById('timeV1Minutes');
|
|||
|
|
|
|||
|
|
if (!startIn || !endIn || !minIn) return;
|
|||
|
|
|
|||
|
|
const parseTime = (val) => {
|
|||
|
|
if (!val) return null;
|
|||
|
|
const [h,m] = val.split(':').map(Number);
|
|||
|
|
return (h * 60) + m;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const toTimeStr = (totalMins) => {
|
|||
|
|
const h = Math.floor(totalMins / 60) % 24;
|
|||
|
|
const m = totalMins % 60;
|
|||
|
|
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const recalculate = (trigger) => {
|
|||
|
|
const s = parseTime(startIn.value);
|
|||
|
|
const e = parseTime(endIn.value);
|
|||
|
|
const dur = parseInt(minIn.value);
|
|||
|
|
|
|||
|
|
if (trigger === 'start' || trigger === 'end') {
|
|||
|
|
if (s !== null && e !== null) {
|
|||
|
|
let diff = e - s;
|
|||
|
|
if (diff < 0) diff += 24*60;
|
|||
|
|
minIn.value = diff;
|
|||
|
|
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
|
|||
|
|
endIn.value = toTimeStr(s + dur);
|
|||
|
|
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
|
|||
|
|
let base = e - dur;
|
|||
|
|
while (base < 0) base += 24*60;
|
|||
|
|
startIn.value = toTimeStr(base);
|
|||
|
|
}
|
|||
|
|
} else if (trigger === 'min') {
|
|||
|
|
if (s !== null && !isNaN(dur) && dur > 0) {
|
|||
|
|
endIn.value = toTimeStr(s + dur);
|
|||
|
|
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
|
|||
|
|
let base = e - dur;
|
|||
|
|
while(base < 0) base+=24*60;
|
|||
|
|
startIn.value = toTimeStr(base);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
startIn.addEventListener('change', () => recalculate('start'));
|
|||
|
|
endIn.addEventListener('change', () => recalculate('end'));
|
|||
|
|
minIn.addEventListener('input', () => recalculate('min'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getTimeV1EmployeeId() {
|
|||
|
|
const val = document.getElementById('timeV1EmployeeId')?.value;
|
|||
|
|
return val ? Number(val) : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createManualTimeV1(event) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0);
|
|||
|
|
|
|||
|
|
if (minutes <= 0) {
|
|||
|
|
alert('Indtast minutter over 0');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dateVal = document.getElementById('timeV1Date')?.value || null;
|
|||
|
|
const tStart = document.getElementById('timeV1Start')?.value;
|
|||
|
|
const tEnd = document.getElementById('timeV1End')?.value;
|
|||
|
|
|
|||
|
|
let startObj = null;
|
|||
|
|
let endObj = null;
|
|||
|
|
|
|||
|
|
if (dateVal && tStart) {
|
|||
|
|
try {
|
|||
|
|
const l = new Date(`${dateVal}T${tStart}:00`);
|
|||
|
|
startObj = l.toISOString();
|
|||
|
|
} catch(e){}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (dateVal && tEnd) {
|
|||
|
|
try {
|
|||
|
|
const l = new Date(`${dateVal}T${tEnd}:00`);
|
|||
|
|
if (startObj && new Date(startObj) > l) {
|
|||
|
|
l.setDate(l.getDate() + 1);
|
|||
|
|
}
|
|||
|
|
endObj = l.toISOString();
|
|||
|
|
} catch(e){}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
sag_id: timeCaseId,
|
|||
|
|
medarbejder_id: getTimeV1EmployeeId(),
|
|||
|
|
faktisk_tid_min: minutes,
|
|||
|
|
worked_date: dateVal,
|
|||
|
|
entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
|
|||
|
|
entry_status: document.getElementById('timeV1Status')?.value || 'afventer',
|
|||
|
|
beskrivelse: document.getElementById('timeV1Description')?.value || null,
|
|||
|
|
kilde: 'manuel',
|
|||
|
|
start_tid: startObj,
|
|||
|
|
slut_tid: endObj
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/timetracking/time/manual', {
|
|||
|
|
method: 'POST',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let errMsg = res.statusText;
|
|||
|
|
try { const j = await res.json(); errMsg = j.detail || JSON.stringify(j); } catch(e) {}
|
|||
|
|
throw new Error(`${res.status}: ${errMsg}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const minutesInput = document.getElementById('timeV1Minutes');
|
|||
|
|
const descInput = document.getElementById('timeV1Description');
|
|||
|
|
const startIn = document.getElementById('timeV1Start');
|
|||
|
|
const endIn = document.getElementById('timeV1End');
|
|||
|
|
|
|||
|
|
if (minutesInput) minutesInput.value = '';
|
|||
|
|
if (descInput) descInput.value = '';
|
|||
|
|
if (startIn) startIn.value = '';
|
|||
|
|
if (endIn) endIn.value = '';
|
|||
|
|
|
|||
|
|
await loadTimeTrackingTab();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl'));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
bindTimeV1Calculations();
|
|||
|
|
const dateInput = document.getElementById('timeV1Date');
|
|||
|
|
if (dateInput && !dateInput.value) {
|
|||
|
|
dateInput.valueAsDate = new Date();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
let reminderUserId = null;
|
|||
|
|
const remindersCaseId = {{ case.id }};
|
|||
|
|
|
|||
|
|
function getReminderUserId() {
|
|||
|
|
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
|||
|
|
if (token) {
|
|||
|
|
try {
|
|||
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|||
|
|
return payload.sub || payload.user_id;
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('Could not decode token for reminder user_id');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const metaTag = document.querySelector('meta[name="user-id"]');
|
|||
|
|
if (metaTag) return metaTag.getAttribute('content');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function ensureReminderUserId() {
|
|||
|
|
const localId = getReminderUserId();
|
|||
|
|
if (localId) return localId;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
|||
|
|
if (!res.ok) return null;
|
|||
|
|
const me = await res.json();
|
|||
|
|
return me?.id || me?.user_id || null;
|
|||
|
|
} catch (err) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatReminderDate(value) {
|
|||
|
|
if (!value) return '-';
|
|||
|
|
const date = new Date(value);
|
|||
|
|
if (Number.isNaN(date.getTime())) return '-';
|
|||
|
|
return date.toLocaleString('da-DK', { hour12: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateReminderTriggerFields() {
|
|||
|
|
const triggerType = document.getElementById('rem_trigger_type')?.value;
|
|||
|
|
const timeWrap = document.getElementById('rem_trigger_time_wrap');
|
|||
|
|
const statusWrap = document.getElementById('rem_trigger_status_wrap');
|
|||
|
|
if (timeWrap && statusWrap) {
|
|||
|
|
if (triggerType === 'status_change') {
|
|||
|
|
timeWrap.classList.add('d-none');
|
|||
|
|
statusWrap.classList.remove('d-none');
|
|||
|
|
} else {
|
|||
|
|
timeWrap.classList.remove('d-none');
|
|||
|
|
statusWrap.classList.add('d-none');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateReminderRecurrenceFields() {
|
|||
|
|
const recurrenceType = document.getElementById('rem_recurrence_type')?.value;
|
|||
|
|
const dowWrap = document.getElementById('rem_recurrence_dow_wrap');
|
|||
|
|
const domWrap = document.getElementById('rem_recurrence_dom_wrap');
|
|||
|
|
if (!dowWrap || !domWrap) return;
|
|||
|
|
dowWrap.classList.toggle('d-none', recurrenceType !== 'weekly');
|
|||
|
|
domWrap.classList.toggle('d-none', recurrenceType !== 'monthly');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCreateReminderModal(defaultEventType) {
|
|||
|
|
reminderUserId = getReminderUserId();
|
|||
|
|
const warning = document.getElementById('rem_user_warning');
|
|||
|
|
if (warning) warning.classList.toggle('d-none', !!reminderUserId);
|
|||
|
|
|
|||
|
|
const form = document.getElementById('createReminderForm');
|
|||
|
|
if (form) form.reset();
|
|||
|
|
document.getElementById('rem_notify_frontend').checked = true;
|
|||
|
|
document.getElementById('rem_priority').value = 'normal';
|
|||
|
|
document.getElementById('rem_event_type').value = defaultEventType || 'reminder';
|
|||
|
|
document.getElementById('rem_trigger_type').value = 'time_based';
|
|||
|
|
document.getElementById('rem_recurrence_type').value = 'once';
|
|||
|
|
updateReminderTriggerFields();
|
|||
|
|
updateReminderRecurrenceFields();
|
|||
|
|
new bootstrap.Modal(document.getElementById('createReminderModal')).show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadReminders() {
|
|||
|
|
const list = document.getElementById('remindersList');
|
|||
|
|
if (!list) return;
|
|||
|
|
reminderUserId = await ensureReminderUserId();
|
|||
|
|
|
|||
|
|
if (!reminderUserId) {
|
|||
|
|
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke finde bruger-id.</div>';
|
|||
|
|
setModuleContentState('reminders', true);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
list.innerHTML = '<div class="p-4 text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Henter reminders...</div>';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${remindersCaseId}/reminders?user_id=${reminderUserId}`);
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke hente reminders');
|
|||
|
|
const reminders = await res.json();
|
|||
|
|
renderReminders(reminders);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
list.innerHTML = '<div class="p-4 text-center text-danger">Fejl ved hentning af reminders</div>';
|
|||
|
|
setModuleContentState('reminders', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderReminders(reminders) {
|
|||
|
|
const list = document.getElementById('remindersList');
|
|||
|
|
if (!list) return;
|
|||
|
|
if (!reminders || reminders.length === 0) {
|
|||
|
|
list.innerHTML = '<div class="p-4 text-center text-muted">Ingen reminders endnu.</div>';
|
|||
|
|
setModuleContentState('reminders', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const triggerLabels = {
|
|||
|
|
time_based: 'Tidspunkt',
|
|||
|
|
status_change: 'Status ændring',
|
|||
|
|
deadline_approaching: 'Deadline'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const eventTypeLabels = {
|
|||
|
|
reminder: 'Reminder',
|
|||
|
|
meeting: 'Moede',
|
|||
|
|
technician_visit: 'Teknikerbesoeg',
|
|||
|
|
obs: 'OBS',
|
|||
|
|
deadline: 'Deadline'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const recurrenceLabels = {
|
|||
|
|
once: 'Én gang',
|
|||
|
|
daily: 'Dagligt',
|
|||
|
|
weekly: 'Ugentligt',
|
|||
|
|
monthly: 'Månedligt'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
list.innerHTML = reminders.map(reminder => {
|
|||
|
|
const nextCheck = formatReminderDate(reminder.next_check_at);
|
|||
|
|
const createdAt = formatReminderDate(reminder.created_at);
|
|||
|
|
const isActive = reminder.is_active;
|
|||
|
|
const statusBadge = isActive
|
|||
|
|
? '<span class="badge bg-success">Aktiv</span>'
|
|||
|
|
: '<span class="badge bg-secondary">Inaktiv</span>';
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="list-group-item">
|
|||
|
|
<div class="d-flex justify-content-between align-items-start">
|
|||
|
|
<div class="me-3">
|
|||
|
|
<div class="fw-bold">${reminder.title}</div>
|
|||
|
|
<div class="text-muted small">${reminder.message || '-'} </div>
|
|||
|
|
<div class="small text-muted mt-1">
|
|||
|
|
Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type}
|
|||
|
|
</div>
|
|||
|
|
<div class="small text-muted">Næste: ${nextCheck} · Oprettet: ${createdAt}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex flex-column align-items-end gap-2">
|
|||
|
|
${statusBadge}
|
|||
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteReminder(${reminder.id})">
|
|||
|
|
<i class="bi bi-trash"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
setModuleContentState('reminders', true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveReminder() {
|
|||
|
|
reminderUserId = await ensureReminderUserId();
|
|||
|
|
if (!reminderUserId) {
|
|||
|
|
alert('Mangler bruger-id. Log ind igen.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const title = document.getElementById('rem_title').value.trim();
|
|||
|
|
const message = document.getElementById('rem_message').value.trim();
|
|||
|
|
const priority = document.getElementById('rem_priority').value;
|
|||
|
|
const eventType = document.getElementById('rem_event_type').value;
|
|||
|
|
const triggerType = document.getElementById('rem_trigger_type').value;
|
|||
|
|
const scheduledAtValue = document.getElementById('rem_scheduled_at').value;
|
|||
|
|
const targetStatus = document.getElementById('rem_target_status').value;
|
|||
|
|
const recurrenceType = document.getElementById('rem_recurrence_type').value;
|
|||
|
|
const recurrenceDow = document.getElementById('rem_recurrence_dow').value;
|
|||
|
|
const recurrenceDom = document.getElementById('rem_recurrence_dom').value;
|
|||
|
|
const notifyFrontend = document.getElementById('rem_notify_frontend').checked;
|
|||
|
|
const notifyEmail = document.getElementById('rem_notify_email').checked;
|
|||
|
|
const notifyMattermost = document.getElementById('rem_notify_mattermost').checked;
|
|||
|
|
const overridePrefs = document.getElementById('rem_override_prefs').checked;
|
|||
|
|
|
|||
|
|
if (!title) {
|
|||
|
|
alert('Titel er påkrævet');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let triggerConfig = {};
|
|||
|
|
let scheduledAt = null;
|
|||
|
|
|
|||
|
|
if (triggerType === 'status_change') {
|
|||
|
|
if (!targetStatus) {
|
|||
|
|
alert('Vælg en status for statusændring');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
triggerConfig = { target_status: targetStatus };
|
|||
|
|
} else {
|
|||
|
|
if (!scheduledAtValue) {
|
|||
|
|
alert('Vælg et tidspunkt');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
scheduledAt = new Date(scheduledAtValue).toISOString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
title,
|
|||
|
|
message: message || null,
|
|||
|
|
priority,
|
|||
|
|
event_type: eventType,
|
|||
|
|
trigger_type: triggerType,
|
|||
|
|
trigger_config: triggerConfig,
|
|||
|
|
recipient_user_ids: [Number(reminderUserId)],
|
|||
|
|
recipient_emails: [],
|
|||
|
|
notify_mattermost: notifyMattermost,
|
|||
|
|
notify_email: notifyEmail,
|
|||
|
|
notify_frontend: notifyFrontend,
|
|||
|
|
override_user_preferences: overridePrefs,
|
|||
|
|
recurrence_type: recurrenceType,
|
|||
|
|
recurrence_day_of_week: recurrenceType === 'weekly' ? Number(recurrenceDow) : null,
|
|||
|
|
recurrence_day_of_month: recurrenceType === 'monthly' ? Number(recurrenceDom) : null,
|
|||
|
|
scheduled_at: scheduledAt
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${remindersCaseId}/reminders?user_id=${reminderUserId}`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke oprette reminder');
|
|||
|
|
}
|
|||
|
|
bootstrap.Modal.getInstance(document.getElementById('createReminderModal')).hide();
|
|||
|
|
await loadReminders();
|
|||
|
|
await loadCaseCalendar();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteReminder(reminderId) {
|
|||
|
|
if (!confirm('Vil du slette denne reminder?')) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, { method: 'DELETE' });
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke slette reminder');
|
|||
|
|
await loadReminders();
|
|||
|
|
await loadCaseCalendar();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatCalendarEvent(event) {
|
|||
|
|
const dateLabel = formatReminderDate(event.start);
|
|||
|
|
const typeLabelMap = {
|
|||
|
|
reminder: 'Reminder',
|
|||
|
|
meeting: 'Moede',
|
|||
|
|
technician_visit: 'Teknikerbesoeg',
|
|||
|
|
obs: 'OBS',
|
|||
|
|
deadline: 'Deadline',
|
|||
|
|
deferred: 'Deferred'
|
|||
|
|
};
|
|||
|
|
const typeLabel = typeLabelMap[event.event_kind] || event.event_kind || 'Reminder';
|
|||
|
|
return `
|
|||
|
|
<a href="${event.url}" class="list-group-item list-group-item-action">
|
|||
|
|
<div class="d-flex justify-content-between">
|
|||
|
|
<div>
|
|||
|
|
<div class="fw-semibold">${event.title || 'Aftale'}</div>
|
|||
|
|
<div class="text-muted small">${typeLabel} · ${dateLabel}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadCaseCalendar() {
|
|||
|
|
const currentList = document.getElementById('caseCalendarCurrent');
|
|||
|
|
const childrenList = document.getElementById('caseCalendarChildren');
|
|||
|
|
if (!currentList || !childrenList) return;
|
|||
|
|
|
|||
|
|
currentList.innerHTML = '<div class="text-muted small">Indlæser aftaler...</div>';
|
|||
|
|
childrenList.innerHTML = '<div class="text-muted small">Indlæser børnesager...</div>';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${remindersCaseId}/calendar-events?include_children=true`);
|
|||
|
|
if (!res.ok) throw new Error('Kunne ikke hente kalenderaftaler');
|
|||
|
|
const data = await res.json();
|
|||
|
|
|
|||
|
|
const currentEvents = data.current || [];
|
|||
|
|
const childGroups = data.children || [];
|
|||
|
|
const childCount = childGroups.reduce((sum, child) => sum + (child.events || []).length, 0);
|
|||
|
|
const hasAnyEvents = currentEvents.length > 0 || childCount > 0;
|
|||
|
|
|
|||
|
|
if (!currentEvents.length) {
|
|||
|
|
currentList.innerHTML = '<div class="text-muted small">Ingen aftaler for denne sag.</div>';
|
|||
|
|
} else {
|
|||
|
|
currentList.innerHTML = currentEvents
|
|||
|
|
.map(formatCalendarEvent)
|
|||
|
|
.join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!childGroups.length) {
|
|||
|
|
childrenList.innerHTML = '<div class="text-muted small">Ingen børnesager.</div>';
|
|||
|
|
} else {
|
|||
|
|
childrenList.innerHTML = childGroups.map(child => {
|
|||
|
|
const eventsHtml = (child.events || []).length
|
|||
|
|
? child.events.map(formatCalendarEvent).join('')
|
|||
|
|
: '<div class="text-muted small">Ingen aftaler.</div>';
|
|||
|
|
return `
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<div class="fw-semibold mb-1">${child.case_title}</div>
|
|||
|
|
<div class="list-group">
|
|||
|
|
${eventsHtml}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setModuleContentState('calendar', hasAnyEvents);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
currentList.innerHTML = '<div class="text-danger small">Fejl ved hentning af aftaler.</div>';
|
|||
|
|
childrenList.innerHTML = '';
|
|||
|
|
setModuleContentState('calendar', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|||
|
|
updateReminderTriggerFields();
|
|||
|
|
updateReminderRecurrenceFields();
|
|||
|
|
loadReminders();
|
|||
|
|
loadCaseCalendar();
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<!-- Modals for Solution (Inserted here) -->
|
|||
|
|
<div class="modal fade" id="createSolutionModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-lg">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Opret Løsning</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="solutionForm">
|
|||
|
|
<input type="hidden" id="sol_sag_id" value="{{ case.id }}">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Titel *</label>
|
|||
|
|
<input type="text" class="form-control" id="sol_title" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="row mb-3">
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Type</label>
|
|||
|
|
<select class="form-select" id="sol_type">
|
|||
|
|
<option value="Support">Support</option>
|
|||
|
|
<option value="Drift">Drift</option>
|
|||
|
|
<option value="Konsulent">Konsulent</option>
|
|||
|
|
<option value="Infrastruktur">Infrastruktur</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<label class="form-label">Resultat</label>
|
|||
|
|
<select class="form-select" id="sol_result">
|
|||
|
|
<option value="Løst">Løst</option>
|
|||
|
|
<option value="Delvist">Delvist</option>
|
|||
|
|
<option value="Workaround">Workaround</option>
|
|||
|
|
<option value="Ej løst">Ej løst</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Beskrivelse</label>
|
|||
|
|
<textarea class="form-control" id="sol_desc" rows="5"></textarea>
|
|||
|
|
</div>
|
|||
|
|
<div class="border-top pt-3">
|
|||
|
|
<div class="form-check mb-3">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="sol_add_time">
|
|||
|
|
<label class="form-check-label" for="sol_add_time">
|
|||
|
|
Registrer tid med det samme
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
<div id="sol_time_fields" class="row g-3 d-none">
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Dato</label>
|
|||
|
|
<input type="date" class="form-control" id="sol_time_date">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Tid brugt</label>
|
|||
|
|
<div class="input-group">
|
|||
|
|
<input type="number" class="form-control" id="sol_time_hours" min="0" placeholder="tt" step="1">
|
|||
|
|
<span class="input-group-text">:</span>
|
|||
|
|
<input type="number" class="form-control" id="sol_time_minutes" min="0" placeholder="mm" step="1">
|
|||
|
|
</div>
|
|||
|
|
<div class="form-text" id="sol_time_total">Total: 0.00 timer</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Beskrivelse</label>
|
|||
|
|
<input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="sol_time_internal">
|
|||
|
|
<label class="form-check-label text-muted" for="sol_time_internal">
|
|||
|
|
Skjul for kunde (intern registrering)
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveSolution()">Gem Løsning</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Modal for Sale Item -->
|
|||
|
|
<div class="modal fade" id="saleItemModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-lg">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title"><i class="bi bi-basket3"></i> Varelinje</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="saleItemForm">
|
|||
|
|
<input type="hidden" id="sale_item_id">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Type *</label>
|
|||
|
|
<select class="form-select" id="sale_type">
|
|||
|
|
<option value="sale">Salg</option>
|
|||
|
|
<option value="purchase">Køb</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Status *</label>
|
|||
|
|
<select class="form-select" id="sale_status">
|
|||
|
|
<option value="draft">Kladde</option>
|
|||
|
|
<option value="confirmed">Bekræftet</option>
|
|||
|
|
<option value="cancelled">Annulleret</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Dato</label>
|
|||
|
|
<input type="date" class="form-control" id="sale_date">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Beskrivelse *</label>
|
|||
|
|
<input type="text" class="form-control" id="sale_description" placeholder="F.eks. Switch, montage, kørsel">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Antal</label>
|
|||
|
|
<input type="number" class="form-control" id="sale_quantity" step="0.01" min="0">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Enhed</label>
|
|||
|
|
<input type="text" class="form-control" id="sale_unit" placeholder="stk, timer">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Enhedspris</label>
|
|||
|
|
<input type="number" class="form-control" id="sale_unit_price" step="0.01" min="0">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-3">
|
|||
|
|
<label class="form-label">Linjesum *</label>
|
|||
|
|
<input type="number" class="form-control" id="sale_amount" step="0.01" min="0">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Valuta</label>
|
|||
|
|
<input type="text" class="form-control" id="sale_currency" value="DKK">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-8">
|
|||
|
|
<label class="form-label">Reference</label>
|
|||
|
|
<input type="text" class="form-control" id="sale_external_ref" placeholder="Valgfri reference">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12 d-none" id="sale_purchase_fields">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Indkøbsformål</label>
|
|||
|
|
<select class="form-select" id="sale_purchase_purpose">
|
|||
|
|
<option value="">Vælg formål</option>
|
|||
|
|
<option value="salg">Salg</option>
|
|||
|
|
<option value="lager">Lager</option>
|
|||
|
|
<option value="asset">Asset</option>
|
|||
|
|
<option value="intern_brug">Intern brug</option>
|
|||
|
|
<option value="retur_reklamation">Retur/reklamation</option>
|
|||
|
|
<option value="projekt_omkostning">Projektomkostning</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Leverandørfaktura ID</label>
|
|||
|
|
<input type="number" class="form-control" id="sale_supplier_invoice_id" min="1" step="1" placeholder="Fx 1254">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-4">
|
|||
|
|
<label class="form-label">Fakturalinje ID</label>
|
|||
|
|
<input type="number" class="form-control" id="sale_supplier_invoice_line_id" min="1" step="1" placeholder="Fx 8921">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveSaleItem()">Gem varelinje</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Modal for Internal Time -->
|
|||
|
|
<div class="modal fade" id="createTimeModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title"><i class="bi bi-clock-history"></i> Registrer Tid</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="timeForm">
|
|||
|
|
<input type="hidden" id="time_sag_id" value="{{ case.id }}">
|
|||
|
|
<div class="row g-3">
|
|||
|
|
<div class="col-12 col-md-6">
|
|||
|
|
<label class="form-label">Dato *</label>
|
|||
|
|
<input type="date" class="form-control" id="time_date" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12 col-md-6">
|
|||
|
|
<label class="form-label">Tid brugt *</label>
|
|||
|
|
<div class="input-group">
|
|||
|
|
<span class="input-group-text">Min.</span>
|
|||
|
|
<input type="number" class="form-control" id="time_total_minutes" min="1" placeholder="45" step="1" required>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label">Starttid</label>
|
|||
|
|
<input type="time" class="form-control" id="time_start_input">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label">Sluttid</label>
|
|||
|
|
<input type="time" class="form-control" id="time_end_input">
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label">Type</label>
|
|||
|
|
<select class="form-select" id="time_work_type">
|
|||
|
|
<option value="support" selected>Support</option>
|
|||
|
|
<option value="troubleshooting">Fejlsøgning</option>
|
|||
|
|
<option value="development">Udvikling</option>
|
|||
|
|
<option value="on_site">Kørsel / On-site</option>
|
|||
|
|
<option value="meeting">Møde</option>
|
|||
|
|
<option value="other">Andet</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label">Afregning</label>
|
|||
|
|
<select class="form-select" id="time_billing_method">
|
|||
|
|
<option value="invoice" selected>Faktura</option>
|
|||
|
|
{% if prepaid_cards %}
|
|||
|
|
<optgroup label="Klippekort">
|
|||
|
|
{% for card in prepaid_cards %}
|
|||
|
|
<option value="card_{{ card.id }}">💳 Klippekort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t tilbage{% if card.expires_at %} • Udløber {{ card.expires_at }}{% endif %})</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</optgroup>
|
|||
|
|
{% endif %}
|
|||
|
|
{% if fixed_price_agreements %}
|
|||
|
|
<optgroup label="Fastpris Aftaler">
|
|||
|
|
{% for agr in fixed_price_agreements %}
|
|||
|
|
<option value="fpa_{{ agr.id }}">📋 Fastpris #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t tilbage / {{ '%.0f' % agr.monthly_hours }}t/måned)</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</optgroup>
|
|||
|
|
{% endif %}
|
|||
|
|
<option value="internal">Internt / Ingen faktura</option>
|
|||
|
|
<option value="warranty">Garanti / Reklamation</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-12">
|
|||
|
|
<label class="form-label">Beskrivelse</label>
|
|||
|
|
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="saveTime()">
|
|||
|
|
<i class="bi bi-save"></i> Gem Tid
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Script for Solution/Time -->
|
|||
|
|
<script>
|
|||
|
|
function showCreateSolutionModal() {
|
|||
|
|
const addTimeCheckbox = document.getElementById('sol_add_time');
|
|||
|
|
const timeFields = document.getElementById('sol_time_fields');
|
|||
|
|
if (addTimeCheckbox && timeFields) {
|
|||
|
|
addTimeCheckbox.checked = false;
|
|||
|
|
timeFields.classList.add('d-none');
|
|||
|
|
}
|
|||
|
|
const timeDate = document.getElementById('sol_time_date');
|
|||
|
|
if (timeDate) timeDate.valueAsDate = new Date();
|
|||
|
|
const timeHours = document.getElementById('sol_time_hours');
|
|||
|
|
const timeMinutes = document.getElementById('sol_time_minutes');
|
|||
|
|
const timeTotal = document.getElementById('sol_time_total');
|
|||
|
|
if (timeHours) timeHours.value = '';
|
|||
|
|
if (timeMinutes) timeMinutes.value = '';
|
|||
|
|
if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer';
|
|||
|
|
const timeDesc = document.getElementById('sol_time_desc');
|
|||
|
|
if (timeDesc) timeDesc.value = '';
|
|||
|
|
const timeInternal = document.getElementById('sol_time_internal');
|
|||
|
|
if (timeInternal) timeInternal.checked = false;
|
|||
|
|
new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateSolutionTimeTotal() {
|
|||
|
|
const h = parseInt(document.getElementById('sol_time_hours').value) || 0;
|
|||
|
|
const m = parseInt(document.getElementById('sol_time_minutes').value) || 0;
|
|||
|
|
const total = h + (m / 60);
|
|||
|
|
const output = document.getElementById('sol_time_total');
|
|||
|
|
if (output) output.textContent = `Total: ${total.toFixed(2)} timer`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveSolution() {
|
|||
|
|
const data = {
|
|||
|
|
sag_id: document.getElementById('sol_sag_id').value,
|
|||
|
|
title: document.getElementById('sol_title').value,
|
|||
|
|
solution_type: document.getElementById('sol_type').value,
|
|||
|
|
result: document.getElementById('sol_result').value,
|
|||
|
|
description: document.getElementById('sol_desc').value,
|
|||
|
|
created_by_user_id: 1 // TODO: Get from auth
|
|||
|
|
};
|
|||
|
|
const addTime = document.getElementById('sol_add_time')?.checked;
|
|||
|
|
const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0;
|
|||
|
|
const timeMinutes = parseInt(document.getElementById('sol_time_minutes').value) || 0;
|
|||
|
|
const timeTotal = timeHours + (timeMinutes / 60);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${data.sag_id}/solution`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify(data)
|
|||
|
|
});
|
|||
|
|
if (res.ok) {
|
|||
|
|
if (addTime && timeTotal > 0) {
|
|||
|
|
const solution = await res.json();
|
|||
|
|
const timePayload = {
|
|||
|
|
sag_id: data.sag_id,
|
|||
|
|
solution_id: solution.id,
|
|||
|
|
description: document.getElementById('sol_time_desc').value || data.title,
|
|||
|
|
original_hours: timeTotal,
|
|||
|
|
worked_date: document.getElementById('sol_time_date').value || null,
|
|||
|
|
is_internal: document.getElementById('sol_time_internal').checked,
|
|||
|
|
work_type: 'support'
|
|||
|
|
};
|
|||
|
|
const timeRes = await fetch('/api/v1/timetracking/entries/internal', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify(timePayload)
|
|||
|
|
});
|
|||
|
|
if (!timeRes.ok) {
|
|||
|
|
alert('Løsning oprettet, men tid kunne ikke registreres');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert('Fejl ved oprettelse af løsning');
|
|||
|
|
}
|
|||
|
|
} catch(e) { console.error(e); alert('Fejl'); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showAddTimeModal() {
|
|||
|
|
// Set date to today
|
|||
|
|
document.getElementById('time_date').valueAsDate = new Date();
|
|||
|
|
|
|||
|
|
// Reset fields
|
|||
|
|
if(document.getElementById('time_total_minutes')) {
|
|||
|
|
document.getElementById('time_total_minutes').value = '';
|
|||
|
|
document.getElementById('time_start_input').value = '';
|
|||
|
|
document.getElementById('time_end_input').value = '';
|
|||
|
|
}
|
|||
|
|
document.getElementById('time_desc').value = '';
|
|||
|
|
if(document.getElementById('time_internal')) document.getElementById('time_internal').checked = false;
|
|||
|
|
if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice';
|
|||
|
|
if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support';
|
|||
|
|
|
|||
|
|
new bootstrap.Modal(document.getElementById('createTimeModal')).show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Auto-calculate total hours
|
|||
|
|
/* removed updateTimeTotal */
|
|||
|
|
|
|||
|
|
// Add listeners safely
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
const hInput = document.getElementById('time_hours_input');
|
|||
|
|
const mInput = document.getElementById('time_minutes_input');
|
|||
|
|
|
|||
|
|
const solAddTime = document.getElementById('sol_add_time');
|
|||
|
|
const solFields = document.getElementById('sol_time_fields');
|
|||
|
|
if (solAddTime && solFields) {
|
|||
|
|
solAddTime.addEventListener('change', () => {
|
|||
|
|
solFields.classList.toggle('d-none', !solAddTime.checked);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const solHours = document.getElementById('sol_time_hours');
|
|||
|
|
const solMinutes = document.getElementById('sol_time_minutes');
|
|||
|
|
if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal);
|
|||
|
|
if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function bindTimeModalCalculations() {
|
|||
|
|
const startIn = document.getElementById('time_start_input');
|
|||
|
|
const endIn = document.getElementById('time_end_input');
|
|||
|
|
const minIn = document.getElementById('time_total_minutes');
|
|||
|
|
|
|||
|
|
if (!startIn || !endIn || !minIn) return;
|
|||
|
|
|
|||
|
|
const parseTime = (val) => {
|
|||
|
|
if (!val) return null;
|
|||
|
|
const [h,m] = val.split(':').map(Number);
|
|||
|
|
return (h * 60) + m;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const toTimeStr = (totalMins) => {
|
|||
|
|
const h = Math.floor(totalMins / 60) % 24;
|
|||
|
|
const m = totalMins % 60;
|
|||
|
|
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const recalculate = (trigger) => {
|
|||
|
|
const s = parseTime(startIn.value);
|
|||
|
|
const e = parseTime(endIn.value);
|
|||
|
|
const dur = parseInt(minIn.value);
|
|||
|
|
|
|||
|
|
if (trigger === 'start' || trigger === 'end') {
|
|||
|
|
if (s !== null && e !== null) {
|
|||
|
|
let diff = e - s;
|
|||
|
|
if (diff < 0) diff += 24*60;
|
|||
|
|
minIn.value = diff;
|
|||
|
|
} else if (s !== null && !isNaN(dur) && dur > 0 && !endIn.value) {
|
|||
|
|
endIn.value = toTimeStr(s + dur);
|
|||
|
|
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
|
|||
|
|
let base = e - dur;
|
|||
|
|
while (base < 0) base += 24*60;
|
|||
|
|
startIn.value = toTimeStr(base);
|
|||
|
|
}
|
|||
|
|
} else if (trigger === 'min') {
|
|||
|
|
if (s !== null && !isNaN(dur) && dur > 0) {
|
|||
|
|
endIn.value = toTimeStr(s + dur);
|
|||
|
|
} else if (e !== null && !isNaN(dur) && dur > 0 && !startIn.value) {
|
|||
|
|
let base = e - dur;
|
|||
|
|
while(base < 0) base+=24*60;
|
|||
|
|
startIn.value = toTimeStr(base);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
startIn.addEventListener('change', () => recalculate('start'));
|
|||
|
|
endIn.addEventListener('change', () => recalculate('end'));
|
|||
|
|
minIn.addEventListener('input', () => recalculate('min'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('DOMContentLoaded', bindTimeModalCalculations);
|
|||
|
|
|
|||
|
|
async function saveTime() {
|
|||
|
|
const mInput = document.getElementById('time_total_minutes');
|
|||
|
|
const minVal = parseInt(mInput ? mInput.value : 0);
|
|||
|
|
if (!minVal || minVal <= 0) {
|
|||
|
|
alert('Indtast en gyldig varighed (minutter).');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const totalHours = minVal / 60;
|
|||
|
|
const dateVal = document.getElementById('time_date').value;
|
|||
|
|
// extract optional start/end limits
|
|||
|
|
const tStart = document.getElementById('time_start_input')?.value;
|
|||
|
|
const tEnd = document.getElementById('time_end_input')?.value;
|
|||
|
|
|
|||
|
|
let startObj = null;
|
|||
|
|
let endObj = null;
|
|||
|
|
if (dateVal && tStart) {
|
|||
|
|
try {
|
|||
|
|
const l = new Date(`${dateVal}T${tStart}:00`);
|
|||
|
|
startObj = l.toISOString();
|
|||
|
|
} catch(e){}
|
|||
|
|
}
|
|||
|
|
if (dateVal && tEnd) {
|
|||
|
|
try {
|
|||
|
|
const l = new Date(`${dateVal}T${tEnd}:00`);
|
|||
|
|
if (startObj && new Date(startObj) > l) {
|
|||
|
|
l.setDate(l.getDate() + 1);
|
|||
|
|
}
|
|||
|
|
endObj = l.toISOString();
|
|||
|
|
} catch(e){}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sagId = document.getElementById('time_sag_id').value;
|
|||
|
|
const payload = {
|
|||
|
|
sag_id: parseInt(sagId),
|
|||
|
|
// Note: saveTime modal expects 'timer' as totalHours currently, let's keep compatibility:
|
|||
|
|
timer: totalHours,
|
|||
|
|
faktisk_tid_min: minVal,
|
|||
|
|
worked_date: dateVal,
|
|||
|
|
start_tid: startObj,
|
|||
|
|
slut_tid: endObj,
|
|||
|
|
description: document.getElementById('time_desc').value,
|
|||
|
|
work_type: document.getElementById('time_work_type').value,
|
|||
|
|
billing_method: document.getElementById('time_billing_method').value
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/timetracking/time/manual', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
if(res.ok) {
|
|||
|
|
window.location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert("Fejl ved registrering af tid");
|
|||
|
|
}
|
|||
|
|
} catch(err) {
|
|||
|
|
console.error(err);
|
|||
|
|
alert("Forbindelsesfejl");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<!-- Kontakt Info Modal -->
|
|||
|
|
<div class="modal fade" id="kontaktModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header" style="background: var(--accent); color: white;">
|
|||
|
|
<h5 class="modal-title">
|
|||
|
|
<i class="bi bi-person-circle me-2"></i>Kontakt Information
|
|||
|
|
</h5>
|
|||
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
{% if hovedkontakt %}
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="small text-muted mb-1">Navn</label>
|
|||
|
|
<div class="fw-bold">{{ hovedkontakt.first_name }} {{ hovedkontakt.last_name }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="small text-muted mb-1">E-mail</label>
|
|||
|
|
<div>
|
|||
|
|
{% if hovedkontakt.email %}
|
|||
|
|
<a href="mailto:{{ hovedkontakt.email }}" style="color: var(--accent);">
|
|||
|
|
<i class="bi bi-envelope me-1"></i>{{ hovedkontakt.email }}
|
|||
|
|
</a>
|
|||
|
|
{% else %}
|
|||
|
|
<span class="text-muted">Ingen email</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="small text-muted mb-1">Telefon</label>
|
|||
|
|
<div>
|
|||
|
|
{% if hovedkontakt.phone %}
|
|||
|
|
<a href="tel:{{ hovedkontakt.phone }}" style="color: var(--accent);">
|
|||
|
|
<i class="bi bi-telephone me-1"></i>{{ hovedkontakt.phone }}
|
|||
|
|
</a>
|
|||
|
|
{% else %}
|
|||
|
|
<span class="text-muted">Ingen telefon</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="small text-muted mb-1">Mobil</label>
|
|||
|
|
<div>
|
|||
|
|
{% if hovedkontakt.mobile %}
|
|||
|
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|||
|
|
<a href="tel:{{ hovedkontakt.mobile }}" style="color: var(--accent);">
|
|||
|
|
<i class="bi bi-phone me-1"></i>{{ hovedkontakt.mobile }}
|
|||
|
|
</a>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt({{ hovedkontakt.mobile|tojson }}, {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|tojson }}, {{ hovedkontakt.id|default('null') }})">SMS</button>
|
|||
|
|
</div>
|
|||
|
|
{% else %}
|
|||
|
|
<span class="text-muted">Ingen mobil</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{% if hovedkontakt.title %}
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="small text-muted mb-1">Titel</label>
|
|||
|
|
<div>{{ hovedkontakt.title }}</div>
|
|||
|
|
</div>
|
|||
|
|
{% endif %}
|
|||
|
|
{% else %}
|
|||
|
|
<p class="text-center text-muted mb-0">Ingen kontakt tilknyttet</p>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Afdeling Modal -->
|
|||
|
|
<div class="modal fade" id="afdelingModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header" style="background: var(--accent); color: white;">
|
|||
|
|
<h5 class="modal-title">
|
|||
|
|
<i class="bi bi-building me-2"></i>Rediger Afdeling
|
|||
|
|
</h5>
|
|||
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<label for="afdelingInput" class="form-label">Afdeling</label>
|
|||
|
|
<input type="text"
|
|||
|
|
class="form-control"
|
|||
|
|
id="afdelingInput"
|
|||
|
|
value="{{ customer.department if customer and customer.department else '' }}"
|
|||
|
|
placeholder="Indtast afdeling">
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-primary" style="background: var(--accent); border: none;" onclick="updateAfdeling()">
|
|||
|
|
Gem
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Nextcloud Modals -->
|
|||
|
|
{% if nextcloud_instance %}
|
|||
|
|
<!-- Create User Modal -->
|
|||
|
|
<div class="modal fade" id="ncCreateUserModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Opret Nextcloud Bruger</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="ncCreateUserForm">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">E-mail</label>
|
|||
|
|
<input type="email" class="form-control" name="email" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Visningsnavn</label>
|
|||
|
|
<input type="text" class="form-control" name="display_name" value="{{ hovedkontakt.first_name if hovedkontakt else '' }} {{ hovedkontakt.last_name if hovedkontakt else '' }}" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Bruger ID (UID)</label>
|
|||
|
|
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
|
|||
|
|
<div class="form-text">Bruges til login. Oftest email.</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Grupper</label>
|
|||
|
|
<input type="text" class="form-control" name="groups" value="Kunder" placeholder="f.eks. Kunder, Ekstern">
|
|||
|
|
<div class="form-text">Komma-separeret liste af grupper</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" name="send_welcome" id="ncSendWelcome" checked>
|
|||
|
|
<label class="form-check-label" for="ncSendWelcome">
|
|||
|
|
Send velkomst-email med kode
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-success" onclick="ncCreateUser()">Opret Bruger</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Disable User Modal -->
|
|||
|
|
<div class="modal fade" id="ncDisableUserModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title text-danger">Luk Nextcloud Bruger</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="ncDisableUserForm">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Bruger ID (UID)</label>
|
|||
|
|
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="alert alert-warning small">
|
|||
|
|
Brugeren vil ikke længere kunne logge ind, men data bevares.
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-danger" onclick="ncDisableUser()">Luk Bruger</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Reset Password Modal -->
|
|||
|
|
<div class="modal fade" id="ncResetPasswordModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Reset Kodeord</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="ncResetPasswordForm">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Bruger ID (UID)</label>
|
|||
|
|
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
|
|||
|
|
</div>
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input" type="checkbox" name="send_email" id="ncSendResetEmail" checked>
|
|||
|
|
<label class="form-check-label" for="ncSendResetEmail">
|
|||
|
|
Send ny kode på email
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-warning" onclick="ncResetPassword()">Reset Kode</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Send Guide Modal -->
|
|||
|
|
<div class="modal fade" id="ncSendGuideModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Send Guide</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="ncSendGuideForm">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Bruger ID (UID)</label>
|
|||
|
|
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
|
|||
|
|
</div>
|
|||
|
|
<p class="small text-muted">Sender en email med start-guide til Nextcloud til brugerens registrerede mail.</p>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-info text-white" onclick="ncSendGuide()">Send Guide</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
{% endif %}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<div id="caseAddSideBackdrop" class="case-add-side-backdrop" onclick="closeCaseModuleAddPanel()"></div>
|
|||
|
|
<button type="button" id="caseAddSideReopen" class="case-add-side-reopen" onclick="openCaseModuleAddPanel()" title="Vis tilføj-panel igen">
|
|||
|
|
<i class="bi bi-layout-sidebar-inset"></i>Tilføj
|
|||
|
|
</button>
|
|||
|
|
<aside id="caseAddSidePanel" class="case-add-side-panel" aria-hidden="true">
|
|||
|
|
<div class="case-add-side-header">
|
|||
|
|
<h6 class="case-add-side-title"><i class="bi bi-plus-square"></i>Tilføj til modul</h6>
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="closeCaseModuleAddPanel()" title="Skjul panel">
|
|||
|
|
<i class="bi bi-chevron-left"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="case-add-side-body">
|
|||
|
|
<div id="caseAddModuleList" class="case-add-module-list"></div>
|
|||
|
|
<div id="caseAddSideWorkspace" class="case-add-workspace">
|
|||
|
|
<div class="text-muted small">Vælg et aktivt modul for at tilføje data.</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<!-- Generic Search Modal -->
|
|||
|
|
<div class="modal fade" id="entitySearchModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog modal-dialog-centered">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title" id="entitySearchTitle">Søg</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div class="input-group mb-3">
|
|||
|
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
|||
|
|
<input type="text" class="form-control" id="entitySearchInput" placeholder="Søg (min. 2 tegn)..." autocomplete="off">
|
|||
|
|
</div>
|
|||
|
|
<div class="text-center d-none" id="entitySearchSpinner">
|
|||
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|||
|
|
</div>
|
|||
|
|
<div id="entitySearchResults" class="list-group list-group-flush" style="max-height: 300px; overflow-y: auto;">
|
|||
|
|
<!-- Results go here -->
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Create Related Case Modal -->
|
|||
|
|
<div class="modal fade" id="createRelatedCaseModal" tabindex="-1" aria-hidden="true">
|
|||
|
|
<div class="modal-dialog">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h5 class="modal-title">Opret ny relateret sag</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="createRelatedForm">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Titel *</label>
|
|||
|
|
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Relationstype *</label>
|
|||
|
|
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
|
|||
|
|
<option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option>
|
|||
|
|
<option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option>
|
|||
|
|
<option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option>
|
|||
|
|
<option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
|
|||
|
|
<div class="alert alert-light border small">
|
|||
|
|
<div class="fw-semibold mb-1">Sådan vælger du korrekt relation</div>
|
|||
|
|
<div><strong>Relateret til</strong>: Samme emne/område, men ingen direkte afhængighed.</div>
|
|||
|
|
<div><strong>Afledt af</strong>: Den nye sag opstår fordi den nuværende sag findes.</div>
|
|||
|
|
<div><strong>Årsag til</strong>: Den nuværende sag opstår fordi den nye sag findes.</div>
|
|||
|
|
<div><strong>Blokkerer</strong>: Løsning i én sag er nødvendig før den anden kan afsluttes.</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">Beskrivelse</label>
|
|||
|
|
<textarea class="form-control" id="newCaseDescription" rows="3"></textarea>
|
|||
|
|
</div>
|
|||
|
|
<div class="alert alert-info small mb-0">
|
|||
|
|
<i class="bi bi-info-circle me-1"></i>
|
|||
|
|
Sagen oprettes for kunden: <strong>{{ case.customer_name }}</strong>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
<button type="button" class="btn btn-primary" onclick="createRelatedCase()">Opret & Link</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
let currentSearchType = null;
|
|||
|
|
let searchDebounceIds = null;
|
|||
|
|
let caseAnyDeskConnectInFlight = false;
|
|||
|
|
const caseIds = {{ case.id }};
|
|||
|
|
const currentCaseTitle = {{ (case.titel or '') | tojson }};
|
|||
|
|
let caseAddPanelInitialized = false;
|
|||
|
|
let caseAddActiveAction = null;
|
|||
|
|
let caseAddOriginalShowRelModal = null;
|
|||
|
|
const CASE_ADD_ACTIONS = [
|
|||
|
|
{ action: 'time', label: 'Tidregistrering', icon: 'bi-clock', moduleKey: 'time', relFn: 'openRelTimeModal' },
|
|||
|
|
{ action: 'note', label: 'Kommentar', icon: 'bi-chat-left-text', moduleKey: 'solution', relFn: 'openRelNoteModal' },
|
|||
|
|
{ action: 'reminder', label: 'Pamindelse', icon: 'bi-bell', moduleKey: 'reminders', relFn: 'openRelReminderModal' },
|
|||
|
|
{ action: 'pipeline', label: 'Salgspipeline', icon: 'bi-graph-up-arrow', moduleKey: 'pipeline', relFn: 'openRelPipelineModal' },
|
|||
|
|
{ action: 'files', label: 'Filer', icon: 'bi-paperclip', moduleKey: 'files', relFn: 'openRelFilesModal' },
|
|||
|
|
{ action: 'hardware', label: 'Hardware', icon: 'bi-cpu', moduleKey: 'hardware', relFn: 'openRelHardwareModal' },
|
|||
|
|
{ action: 'todo', label: 'Opgave', icon: 'bi-check2-square', moduleKey: 'todo-steps', relFn: 'openRelTodoModal' },
|
|||
|
|
{ action: 'solution', label: 'Losning', icon: 'bi-lightbulb', moduleKey: 'solution', relFn: 'openRelSolutionModal' },
|
|||
|
|
{ action: 'sales', label: 'Varekob og salg', icon: 'bi-bag', moduleKey: 'sales', relFn: 'openRelSalesModal' },
|
|||
|
|
{ action: 'subscription', label: 'Abonnement', icon: 'bi-arrow-repeat', moduleKey: 'subscription', relFn: 'openRelSubscriptionModal' },
|
|||
|
|
{ action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function normalizeAnyDeskIdClient(rawValue) {
|
|||
|
|
const raw = String(rawValue || '').trim();
|
|||
|
|
const digits = raw.replace(/\D/g, '');
|
|||
|
|
return digits || raw;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onCaseAnyDeskIdInputChange() {
|
|||
|
|
const input = document.getElementById('caseAnydeskIdInput');
|
|||
|
|
const linkBtn = document.getElementById('caseAnydeskOpenLinkBtn');
|
|||
|
|
if (!input || !linkBtn) return;
|
|||
|
|
|
|||
|
|
const id = normalizeAnyDeskIdClient(input.value);
|
|||
|
|
if (!id) {
|
|||
|
|
linkBtn.style.display = 'none';
|
|||
|
|
linkBtn.setAttribute('href', '#');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input.value = id;
|
|||
|
|
linkBtn.style.display = '';
|
|||
|
|
linkBtn.setAttribute('href', `anydesk:${id}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setCaseAnyDeskInputFromSaved(anydeskId) {
|
|||
|
|
const input = document.getElementById('caseAnydeskIdInput');
|
|||
|
|
if (!input) return;
|
|||
|
|
input.value = normalizeAnyDeskIdClient(anydeskId);
|
|||
|
|
onCaseAnyDeskIdInputChange();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCaseAnyDeskSavedIds(entries) {
|
|||
|
|
const container = document.getElementById('caseAnyDeskSavedIds');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
if (!entries?.length) {
|
|||
|
|
container.innerHTML = '<span class="text-muted small">Ingen gemte AnyDesk IDs på sagen endnu.</span>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = entries.map((entry) => {
|
|||
|
|
const primary = entry?.is_primary ? ' border-primary text-primary' : '';
|
|||
|
|
const hardware = entry?.hardware_label ? ` <span class="text-muted">(${entry.hardware_label})</span>` : '';
|
|||
|
|
const badge = entry?.is_primary ? ' <span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">Primær</span>' : '';
|
|||
|
|
return `<button type="button" class="btn btn-sm btn-outline-secondary${primary}" onclick="setCaseAnyDeskInputFromSaved('${String(entry.anydesk_id || '').replace(/'/g, "\\'")}')">${entry.anydesk_id}${badge}${hardware}</button>`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) {
|
|||
|
|
const select = document.getElementById('caseAnydeskHardwareSelect');
|
|||
|
|
if (!select) return;
|
|||
|
|
|
|||
|
|
let html = '<option value="">Ingen hardware valgt</option>';
|
|||
|
|
|
|||
|
|
if (Array.isArray(caseHardware) && caseHardware.length) {
|
|||
|
|
html += '<optgroup label="Hardware på sagen">';
|
|||
|
|
html += caseHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
|
|||
|
|
html += '</optgroup>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Array.isArray(customerHardware) && customerHardware.length) {
|
|||
|
|
html += '<optgroup label="Andet hardware hos kunden">';
|
|||
|
|
html += customerHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
|
|||
|
|
html += '</optgroup>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
select.innerHTML = html;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadCaseAnyDeskContext() {
|
|||
|
|
const [savedIdsRes, hardwareRes] = await Promise.all([
|
|||
|
|
fetch(`/api/v1/anydesk/cases/${caseIds}/ids`, { credentials: 'include' }),
|
|||
|
|
fetch(`/api/v1/anydesk/cases/${caseIds}/hardware-options`, { credentials: 'include' })
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
if (!savedIdsRes.ok) {
|
|||
|
|
throw new Error('Kunne ikke hente gemte AnyDesk IDs');
|
|||
|
|
}
|
|||
|
|
if (!hardwareRes.ok) {
|
|||
|
|
throw new Error('Kunne ikke hente hardwarevalg');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const savedIdsPayload = await savedIdsRes.json();
|
|||
|
|
const hardwarePayload = await hardwareRes.json();
|
|||
|
|
|
|||
|
|
renderCaseAnyDeskSavedIds(savedIdsPayload?.ids || []);
|
|||
|
|
renderCaseAnyDeskHardwareOptions(hardwarePayload?.case_hardware || [], hardwarePayload?.customer_hardware || []);
|
|||
|
|
|
|||
|
|
const primary = (savedIdsPayload?.ids || []).find((item) => item?.is_primary);
|
|||
|
|
if (primary?.anydesk_id) {
|
|||
|
|
setCaseAnyDeskInputFromSaved(primary.anydesk_id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openCaseAnyDeskModal() {
|
|||
|
|
if (caseAnyDeskConnectInFlight) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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');
|
|||
|
|
if (noteInput) noteInput.value = '';
|
|||
|
|
|
|||
|
|
const saved = document.getElementById('caseAnyDeskSavedIds');
|
|||
|
|
if (saved) {
|
|||
|
|
saved.innerHTML = '<span class="text-muted small">Indlæser...</span>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hardwareSelect = document.getElementById('caseAnydeskHardwareSelect');
|
|||
|
|
if (hardwareSelect) {
|
|||
|
|
hardwareSelect.innerHTML = '<option value="">Indlæser hardware...</option>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const input = document.getElementById('caseAnydeskIdInput');
|
|||
|
|
if (input && !input.value) {
|
|||
|
|
input.value = '';
|
|||
|
|
}
|
|||
|
|
onCaseAnyDeskIdInputChange();
|
|||
|
|
|
|||
|
|
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
|||
|
|
if (connectBtn) {
|
|||
|
|
connectBtn.disabled = false;
|
|||
|
|
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await loadCaseAnyDeskContext();
|
|||
|
|
} catch (error) {
|
|||
|
|
const message = error?.message || 'Kunne ikke hente AnyDesk data';
|
|||
|
|
if (saved) {
|
|||
|
|
saved.innerHTML = `<span class="text-danger small">${message}</span>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
|
|||
|
|
caseAnyDeskModal.show();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function registerCaseAnyDeskSession() {
|
|||
|
|
if (caseAnyDeskConnectInFlight) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const anydeskId = normalizeAnyDeskIdClient(document.getElementById('caseAnydeskIdInput')?.value || '');
|
|||
|
|
const contactIdRaw = document.getElementById('caseAnydeskContactSelect')?.value || '';
|
|||
|
|
const hardwareIdRaw = document.getElementById('caseAnydeskHardwareSelect')?.value || '';
|
|||
|
|
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
|
|||
|
|
|
|||
|
|
if (!anydeskId) {
|
|||
|
|
alert('Udfyld AnyDesk ID');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const customerId = {{ customer.id if customer else 'null' }};
|
|||
|
|
if (!customerId) {
|
|||
|
|
alert('Sagen har ingen kunde - kan ikke starte AnyDesk session');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const createdByUserId = await ensureCaseCurrentUserId();
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
anydesk_id: anydeskId,
|
|||
|
|
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
|
|||
|
|
hardware_asset_id: hardwareIdRaw ? Number(hardwareIdRaw) : null,
|
|||
|
|
note: notes,
|
|||
|
|
make_primary: true,
|
|||
|
|
created_by_user_id: createdByUserId || null
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
caseAnyDeskConnectInFlight = true;
|
|||
|
|
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
|||
|
|
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
|
|||
|
|
if (connectBtn) {
|
|||
|
|
connectBtn.disabled = true;
|
|||
|
|
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Registrerer...';
|
|||
|
|
}
|
|||
|
|
if (openBtn) {
|
|||
|
|
openBtn.disabled = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/anydesk/cases/${caseIds}/connect`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
credentials: 'include',
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const txt = await res.text();
|
|||
|
|
throw new Error(txt || 'Kunne ikke starte AnyDesk quick connect');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await res.json();
|
|||
|
|
if (caseAnyDeskModal && typeof caseAnyDeskModal.hide === 'function') {
|
|||
|
|
caseAnyDeskModal.hide();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (result?.deep_link) {
|
|||
|
|
window.location.href = result.deep_link;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
alert(`AnyDesk forbindelse startet (session: ${result?.session?.id || '-'})`);
|
|||
|
|
if (typeof loadComments === 'function') {
|
|||
|
|
loadComments();
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl'));
|
|||
|
|
} finally {
|
|||
|
|
caseAnyDeskConnectInFlight = false;
|
|||
|
|
if (connectBtn) {
|
|||
|
|
connectBtn.disabled = false;
|
|||
|
|
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
|||
|
|
}
|
|||
|
|
if (openBtn) {
|
|||
|
|
openBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const panel = document.getElementById('caseAddSidePanel');
|
|||
|
|
const backdrop = document.getElementById('caseAddSideBackdrop');
|
|||
|
|
if (!panel || !backdrop) return;
|
|||
|
|
|
|||
|
|
backdrop.classList.add('open');
|
|||
|
|
panel.classList.add('open');
|
|||
|
|
panel.setAttribute('aria-hidden', 'false');
|
|||
|
|
|
|||
|
|
if (!caseAddOriginalShowRelModal && typeof window._showRelModal === 'function') {
|
|||
|
|
caseAddOriginalShowRelModal = window._showRelModal;
|
|||
|
|
}
|
|||
|
|
if (typeof caseAddOriginalShowRelModal === 'function') {
|
|||
|
|
window._showRelModal = renderCaseAddWorkspaceModal;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Remove legacy modal instance to avoid duplicate relQaModalEl IDs
|
|||
|
|
// when side-panel rendering is active.
|
|||
|
|
document.querySelectorAll('body > #relQaModalEl').forEach((el) => el.remove());
|
|||
|
|
|
|||
|
|
renderCaseAddActionList(caseAddActiveAction);
|
|||
|
|
caseAddPanelInitialized = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeCaseModuleAddPanel() {
|
|||
|
|
const panel = document.getElementById('caseAddSidePanel');
|
|||
|
|
const backdrop = document.getElementById('caseAddSideBackdrop');
|
|||
|
|
if (!panel || !backdrop) return;
|
|||
|
|
|
|||
|
|
panel.classList.remove('open');
|
|||
|
|
panel.setAttribute('aria-hidden', 'true');
|
|||
|
|
backdrop.classList.remove('open');
|
|||
|
|
|
|||
|
|
if (typeof caseAddOriginalShowRelModal === 'function') {
|
|||
|
|
window._showRelModal = caseAddOriginalShowRelModal;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const workspace = document.getElementById('caseAddSideWorkspace');
|
|||
|
|
if (workspace) {
|
|||
|
|
workspace.innerHTML = '<div class="text-muted small">Vaelg en handling i venstre side.</div>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) {
|
|||
|
|
const workspace = document.getElementById('caseAddSideWorkspace');
|
|||
|
|
if (!workspace) return;
|
|||
|
|
|
|||
|
|
// Ensure only one relQaModalEl exists. Duplicate IDs can break button
|
|||
|
|
// handlers and value lookups across add forms.
|
|||
|
|
document.querySelectorAll('#relQaModalEl').forEach((el) => {
|
|||
|
|
if (!workspace.contains(el)) {
|
|||
|
|
el.remove();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
workspace.innerHTML = `
|
|||
|
|
<div id="relQaModalEl" class="d-flex flex-column gap-2">
|
|||
|
|
<div id="relQaModalTitle" class="section-title">${title}</div>
|
|||
|
|
<div id="relQaModalBody">${bodyHtml}</div>
|
|||
|
|
<div id="relQaModalFooter" class="d-flex justify-content-end gap-2 border-top pt-2">
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeCaseModuleAddPanel()">Luk</button>
|
|||
|
|
${footerBtns || ''}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
workspace.querySelectorAll('form').forEach((formEl) => {
|
|||
|
|
formEl.addEventListener('submit', (evt) => evt.preventDefault());
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
workspace.querySelectorAll('#relQaModalFooter button').forEach((btnEl) => {
|
|||
|
|
if (!btnEl.getAttribute('type')) {
|
|||
|
|
btnEl.setAttribute('type', 'button');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _isCaseAddModuleEnabled(actionConfig) {
|
|||
|
|
if (!actionConfig?.moduleKey) return true;
|
|||
|
|
if (actionConfig.moduleKey === 'time') return true;
|
|||
|
|
return modulePrefs[actionConfig.moduleKey] !== false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _renderCaseAddModuleToggle(actionConfig) {
|
|||
|
|
if (!actionConfig?.moduleKey) {
|
|||
|
|
return '<span class="d-inline-block" style="width:1rem; height:1rem;"></span>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isTimeModule = actionConfig.moduleKey === 'time';
|
|||
|
|
const isChecked = _isCaseAddModuleEnabled(actionConfig);
|
|||
|
|
return `<input type="checkbox" class="case-add-module-toggle" ${isChecked ? 'checked' : ''} ${isTimeModule ? 'disabled' : ''} onchange="toggleModulePref('${actionConfig.moduleKey}', this.checked)">`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCaseAddActionList(preferredAction = null) {
|
|||
|
|
const listEl = document.getElementById('caseAddModuleList');
|
|||
|
|
if (!listEl) return;
|
|||
|
|
|
|||
|
|
const actions = CASE_ADD_ACTIONS;
|
|||
|
|
if (!actions.length) {
|
|||
|
|
listEl.innerHTML = '<div class="text-muted small">Ingen aktive moduler fundet.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
listEl.innerHTML = actions.map((cfg) => `
|
|||
|
|
<div class="case-add-module-item">
|
|||
|
|
<button type="button" class="case-add-module-btn" id="caseAddAction_${cfg.action}" onclick="openCaseAddAction('${cfg.action}')">
|
|||
|
|
<i class="bi ${cfg.icon} me-1"></i>${cfg.label}
|
|||
|
|
</button>
|
|||
|
|
${_renderCaseAddModuleToggle(cfg)}
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
|
|||
|
|
const fallbackAction = actions[0]?.action || null;
|
|||
|
|
const nextAction = actions.some((cfg) => cfg.action === preferredAction) ? preferredAction : fallbackAction;
|
|||
|
|
if (nextAction) {
|
|||
|
|
openCaseAddAction(nextAction);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openCaseAddAction(actionName) {
|
|||
|
|
document.querySelectorAll('.case-add-module-btn').forEach((btn) => btn.classList.remove('active'));
|
|||
|
|
document.getElementById(`caseAddAction_${actionName}`)?.classList.add('active');
|
|||
|
|
caseAddActiveAction = actionName;
|
|||
|
|
|
|||
|
|
const action = CASE_ADD_ACTIONS.find((cfg) => cfg.action === actionName);
|
|||
|
|
const workspace = document.getElementById('caseAddSideWorkspace');
|
|||
|
|
if (!action || !workspace) return;
|
|||
|
|
|
|||
|
|
workspace.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlaeser formular...</div>';
|
|||
|
|
|
|||
|
|
const relFn = window[action.relFn];
|
|||
|
|
if (typeof relFn !== 'function') {
|
|||
|
|
workspace.innerHTML = '<div class="text-danger small">Modulformular er ikke tilgaengelig endnu.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.querySelectorAll('#relQaModalEl').forEach((existingRelQaEl) => {
|
|||
|
|
if (!workspace.contains(existingRelQaEl)) {
|
|||
|
|
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
|
|||
|
|
if (existingModalInstance) {
|
|||
|
|
existingModalInstance.hide();
|
|||
|
|
}
|
|||
|
|
existingRelQaEl.remove();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await Promise.resolve(relFn(caseIds, currentCaseTitle));
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Could not load module add form', error);
|
|||
|
|
workspace.innerHTML = '<div class="text-danger small">Kunne ikke indlaese formularen.</div>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('keydown', (event) => {
|
|||
|
|
if (event.key === 'Escape') {
|
|||
|
|
const panel = document.getElementById('caseAddSidePanel');
|
|||
|
|
if (panel && panel.classList.contains('open')) {
|
|||
|
|
closeCaseModuleAddPanel();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function openSearchModal(type) {
|
|||
|
|
currentSearchType = type;
|
|||
|
|
const titles = {
|
|||
|
|
'hardware': 'Tilføj Hardware',
|
|||
|
|
'location': 'Tilføj Lokation',
|
|||
|
|
'contact': 'Tilføj Kontakt',
|
|||
|
|
'customer': 'Tilføj Kunde'
|
|||
|
|
};
|
|||
|
|
document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg';
|
|||
|
|
document.getElementById('entitySearchInput').value = '';
|
|||
|
|
document.getElementById('entitySearchResults').innerHTML = '';
|
|||
|
|
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('entitySearchModal'));
|
|||
|
|
modal.show();
|
|||
|
|
|
|||
|
|
setTimeout(() => document.getElementById('entitySearchInput').focus(), 500);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('entitySearchInput').addEventListener('input', function(e) {
|
|||
|
|
clearTimeout(searchDebounceIds);
|
|||
|
|
const query = e.target.value.trim();
|
|||
|
|
if (query.length < 2) {
|
|||
|
|
document.getElementById('entitySearchResults').innerHTML = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
searchDebounceIds = setTimeout(() => performSearch(query), 300);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function performSearch(query) {
|
|||
|
|
document.getElementById('entitySearchSpinner').classList.remove('d-none');
|
|||
|
|
document.getElementById('entitySearchResults').classList.add('d-none');
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
let url = '';
|
|||
|
|
if (currentSearchType === 'hardware') url = `/api/v1/search/hardware?q=${encodeURIComponent(query)}`;
|
|||
|
|
else if (currentSearchType === 'location') url = `/api/v1/search/locations?q=${encodeURIComponent(query)}`;
|
|||
|
|
else if (currentSearchType === 'contact') url = `/api/v1/search/contacts?q=${encodeURIComponent(query)}`;
|
|||
|
|
else if (currentSearchType === 'customer') url = `/api/v1/search/customers?q=${encodeURIComponent(query)}`;
|
|||
|
|
|
|||
|
|
const res = await fetch(url);
|
|||
|
|
if (!res.ok) throw new Error('Search failed');
|
|||
|
|
const results = await res.json();
|
|||
|
|
renderResults(results);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
document.getElementById('entitySearchResults').innerHTML = '<div class="text-danger text-center p-3">Fejl ved søgning</div>';
|
|||
|
|
} finally {
|
|||
|
|
document.getElementById('entitySearchSpinner').classList.add('d-none');
|
|||
|
|
document.getElementById('entitySearchResults').classList.remove('d-none');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderResults(results) {
|
|||
|
|
const container = document.getElementById('entitySearchResults');
|
|||
|
|
if (results.length === 0) {
|
|||
|
|
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = results.map(item => {
|
|||
|
|
let title = '', subtitle = '', icon = '', id = item.id;
|
|||
|
|
|
|||
|
|
if (currentSearchType === 'hardware') {
|
|||
|
|
title = `${item.brand} ${item.model}`;
|
|||
|
|
subtitle = `SN: ${item.serial_number}`;
|
|||
|
|
icon = 'bi-laptop';
|
|||
|
|
} else if (currentSearchType === 'location') {
|
|||
|
|
title = item.name;
|
|||
|
|
subtitle = `${item.address_street || ''} ${item.address_city || ''}`;
|
|||
|
|
icon = 'bi-geo-alt';
|
|||
|
|
} else if (currentSearchType === 'contact') {
|
|||
|
|
title = `${item.first_name} ${item.last_name}`;
|
|||
|
|
subtitle = item.email;
|
|||
|
|
icon = 'bi-person';
|
|||
|
|
} else if (currentSearchType === 'customer') {
|
|||
|
|
title = item.name;
|
|||
|
|
subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`;
|
|||
|
|
icon = 'bi-building';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center" onclick="addEntity(${id})">
|
|||
|
|
<div class="me-3 fs-4 text-muted"><i class="bi ${icon}"></i></div>
|
|||
|
|
<div>
|
|||
|
|
<div class="fw-bold">${title}</div>
|
|||
|
|
<small class="text-muted">${subtitle}</small>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function addEntity(id) {
|
|||
|
|
let url = '', body = {};
|
|||
|
|
|
|||
|
|
if (currentSearchType === 'hardware') {
|
|||
|
|
url = `/api/v1/sag/${caseIds}/hardware`;
|
|||
|
|
body = { hardware_id: id };
|
|||
|
|
} else if (currentSearchType === 'location') {
|
|||
|
|
url = `/api/v1/sag/${caseIds}/locations`;
|
|||
|
|
body = { location_id: id };
|
|||
|
|
} else if (currentSearchType === 'contact') {
|
|||
|
|
url = `/api/v1/sag/${caseIds}/contacts`;
|
|||
|
|
body = { contact_id: id };
|
|||
|
|
} else if (currentSearchType === 'customer') {
|
|||
|
|
url = `/api/v1/sag/${caseIds}/customers`;
|
|||
|
|
body = { customer_id: id };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(url, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify(body)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
alert("Fejl: " + (err.detail || 'Kunne ikke tilføje'));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide();
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert("Fejl: " + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function removeContact(caseId, contactId) {
|
|||
|
|
if(!confirm("Fjern denne kontakt fra sagen?")) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, { method: 'DELETE' });
|
|||
|
|
if (res.ok) window.location.reload();
|
|||
|
|
else alert("Fejl ved sletning");
|
|||
|
|
} catch(e) { alert("Fejl: " + e.message); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openContactRoleModal(contactId, contactName, role, isPrimary) {
|
|||
|
|
document.getElementById('contactRoleContactId').value = contactId;
|
|||
|
|
document.getElementById('contactRoleName').textContent = contactName || '-';
|
|||
|
|
document.getElementById('contactRoleInput').value = role || '';
|
|||
|
|
document.getElementById('contactRolePrimary').checked = !!isPrimary;
|
|||
|
|
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('contactRoleModal'));
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveContactRole() {
|
|||
|
|
const contactId = document.getElementById('contactRoleContactId').value;
|
|||
|
|
const role = document.getElementById('contactRoleInput').value.trim();
|
|||
|
|
const isPrimary = document.getElementById('contactRolePrimary').checked;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/contacts/${contactId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({ role, is_primary: isPrimary })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke opdatere kontakt');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bootstrap.Modal.getInstance(document.getElementById('contactRoleModal')).hide();
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function removeCustomer(caseId, customerId) {
|
|||
|
|
if(!confirm("Fjern denne kunde fra sagen?")) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, { method: 'DELETE' });
|
|||
|
|
if (res.ok) window.location.reload();
|
|||
|
|
else alert("Fejl ved sletning");
|
|||
|
|
} catch(e) { alert("Fejl: " + e.message); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function updateDeferredUntil(value) {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({ deferred_until: value || null })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke opdatere');
|
|||
|
|
}
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function updateDeadline(value) {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({ deadline: value || null })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke opdatere deadline');
|
|||
|
|
}
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shiftDeadlineDays(days) {
|
|||
|
|
const input = document.getElementById('deadlineInput');
|
|||
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|||
|
|
base.setDate(base.getDate() + days);
|
|||
|
|
input.value = base.toISOString().slice(0, 10);
|
|||
|
|
updateDeadline(input.value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shiftDeadlineMonths(months) {
|
|||
|
|
const input = document.getElementById('deadlineInput');
|
|||
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|||
|
|
base.setMonth(base.getMonth() + months);
|
|||
|
|
input.value = base.toISOString().slice(0, 10);
|
|||
|
|
updateDeadline(input.value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openDeadlineModal() {
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('deadlineModal'));
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveDeadlineAll() {
|
|||
|
|
const input = document.getElementById('deadlineInput');
|
|||
|
|
updateDeadline(input.value || null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearDeadlineAll() {
|
|||
|
|
const input = document.getElementById('deadlineInput');
|
|||
|
|
input.value = '';
|
|||
|
|
updateDeadline(null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setDeferredFromInput() {
|
|||
|
|
const input = document.getElementById('deferredUntilInput');
|
|||
|
|
updateDeferredUntil(input.value || null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shiftDeferredDays(days) {
|
|||
|
|
const input = document.getElementById('deferredUntilInput');
|
|||
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|||
|
|
base.setDate(base.getDate() + days);
|
|||
|
|
input.value = base.toISOString().slice(0, 10);
|
|||
|
|
updateDeferredUntil(input.value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shiftDeferredMonths(months) {
|
|||
|
|
const input = document.getElementById('deferredUntilInput');
|
|||
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|||
|
|
base.setMonth(base.getMonth() + months);
|
|||
|
|
input.value = base.toISOString().slice(0, 10);
|
|||
|
|
updateDeferredUntil(input.value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearDeferredUntil() {
|
|||
|
|
const input = document.getElementById('deferredUntilInput');
|
|||
|
|
input.value = '';
|
|||
|
|
updateDeferredUntil(null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openDeferredModal() {
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('deferredModal'));
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findDeferredStatusOptionValue(primaryStatus, fallbackContains) {
|
|||
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|||
|
|
if (!statusSelect) return null;
|
|||
|
|
|
|||
|
|
const targetPrimary = String(primaryStatus || '').trim().toLowerCase();
|
|||
|
|
const targetFallback = String(fallbackContains || '').trim().toLowerCase();
|
|||
|
|
const options = Array.from(statusSelect.options || []);
|
|||
|
|
|
|||
|
|
const primary = options.find((opt) => String(opt.value || '').trim().toLowerCase() === targetPrimary);
|
|||
|
|
if (primary && primary.value) return primary.value;
|
|||
|
|
|
|||
|
|
const fallback = options.find((opt) => String(opt.value || '').trim().toLowerCase().includes(targetFallback));
|
|||
|
|
if (fallback && fallback.value) return fallback.value;
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getDeferredSelectedStatuses() {
|
|||
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|||
|
|
if (!statusSelect) return [];
|
|||
|
|
return Array.from(statusSelect.selectedOptions || [])
|
|||
|
|
.map((opt) => String(opt.value || '').trim())
|
|||
|
|
.filter((value) => value.length > 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setDeferredSelectedStatuses(values) {
|
|||
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|||
|
|
if (!statusSelect) return;
|
|||
|
|
|
|||
|
|
const normalized = new Set((Array.isArray(values) ? values : [])
|
|||
|
|
.map((value) => String(value || '').trim().toLowerCase())
|
|||
|
|
.filter((value) => value.length > 0));
|
|||
|
|
|
|||
|
|
Array.from(statusSelect.options || []).forEach((opt) => {
|
|||
|
|
const key = String(opt.value || '').trim().toLowerCase();
|
|||
|
|
opt.selected = normalized.has(key);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openDeferredModalWithPresetStatus(statusKey) {
|
|||
|
|
const target = String(statusKey || '').trim().toLowerCase();
|
|||
|
|
const fallback = target === 'lukket' ? 'luk' : target;
|
|||
|
|
const value = findDeferredStatusOptionValue(target, fallback);
|
|||
|
|
|
|||
|
|
openDeferredModal();
|
|||
|
|
|
|||
|
|
if (!value) {
|
|||
|
|
showToast(`Status "${statusKey}" findes ikke i listen`, 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|||
|
|
if (statusSelect) {
|
|||
|
|
setDeferredSelectedStatuses([value]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function updateDeferredCaseAndStatus(caseId, statuses) {
|
|||
|
|
try {
|
|||
|
|
const normalizedStatuses = Array.isArray(statuses)
|
|||
|
|
? statuses
|
|||
|
|
.map((status) => String(status || '').trim())
|
|||
|
|
.filter((status) => status.length > 0)
|
|||
|
|
: [];
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
deferred_until_case_id: caseId ? parseInt(caseId, 10) : null,
|
|||
|
|
deferred_until_status: normalizedStatuses.length ? normalizedStatuses.join(', ') : null
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke opdatere');
|
|||
|
|
}
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setDeferredCaseFromInputs() {
|
|||
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|||
|
|
updateDeferredCaseAndStatus(caseSelect.value || null, getDeferredSelectedStatuses());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyDeferredStatusTrigger(primaryStatus, fallbackContains, missingMessage) {
|
|||
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|||
|
|
const statusSelect = document.getElementById('deferredStatusSelect');
|
|||
|
|
if (!caseSelect || !statusSelect) return;
|
|||
|
|
|
|||
|
|
if (!caseSelect.value) {
|
|||
|
|
showToast('Vælg først en relateret sag', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const targetPrimary = String(primaryStatus || '').trim().toLowerCase();
|
|||
|
|
const targetFallback = String(fallbackContains || '').trim().toLowerCase();
|
|||
|
|
const matchValue = findDeferredStatusOptionValue(targetPrimary, targetFallback);
|
|||
|
|
|
|||
|
|
if (!matchValue) {
|
|||
|
|
showToast(missingMessage || 'Status findes ikke i listen', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setDeferredSelectedStatuses([matchValue]);
|
|||
|
|
saveDeferredAll();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyDeferredCloseTrigger() {
|
|||
|
|
applyDeferredStatusTrigger('lukket', 'luk', 'Status "Lukket" findes ikke i listen');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyDeferredResolvedTrigger() {
|
|||
|
|
applyDeferredStatusTrigger('løst', 'løs', 'Status "Løst" findes ikke i listen');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyDeferredAnyStatusChangeTrigger() {
|
|||
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|||
|
|
if (!caseSelect || !caseSelect.value) {
|
|||
|
|
showToast('Vælg først en relateret sag', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setDeferredSelectedStatuses(['__any_change__']);
|
|||
|
|
saveDeferredAll();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearDeferredCase() {
|
|||
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|||
|
|
caseSelect.value = '';
|
|||
|
|
setDeferredSelectedStatuses([]);
|
|||
|
|
updateDeferredCaseAndStatus(null, null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveDeferredAll() {
|
|||
|
|
const input = document.getElementById('deferredUntilInput');
|
|||
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|||
|
|
updateDeferredUntil(input.value || null);
|
|||
|
|
updateDeferredCaseAndStatus(caseSelect.value || null, getDeferredSelectedStatuses());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearDeferredAll() {
|
|||
|
|
const input = document.getElementById('deferredUntilInput');
|
|||
|
|
const caseSelect = document.getElementById('deferredCaseSelect');
|
|||
|
|
input.value = '';
|
|||
|
|
caseSelect.value = '';
|
|||
|
|
setDeferredSelectedStatuses([]);
|
|||
|
|
updateDeferredUntil(null);
|
|||
|
|
updateDeferredCaseAndStatus(null, null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function togglePipelineEdit(forceEdit = null) {
|
|||
|
|
const moduleBody = document.getElementById('pipelineCardBody');
|
|||
|
|
const collapseIcon = document.getElementById('pipelineCollapseIcon');
|
|||
|
|
const view = document.getElementById('pipelineViewMode');
|
|||
|
|
const edit = document.getElementById('pipelineEditMode');
|
|||
|
|
const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit;
|
|||
|
|
|
|||
|
|
if (moduleBody && moduleBody.classList.contains('d-none')) {
|
|||
|
|
moduleBody.classList.remove('d-none');
|
|||
|
|
}
|
|||
|
|
if (collapseIcon) {
|
|||
|
|
collapseIcon.classList.remove('bi-chevron-down');
|
|||
|
|
collapseIcon.classList.add('bi-chevron-up');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (shouldEdit) {
|
|||
|
|
view.classList.add('d-none');
|
|||
|
|
edit.classList.remove('d-none');
|
|||
|
|
} else {
|
|||
|
|
view.classList.remove('d-none');
|
|||
|
|
edit.classList.add('d-none');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (shouldEdit) {
|
|||
|
|
ensurePipelineStagesLoaded();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function togglePipelineModule(forceOpen = null) {
|
|||
|
|
const moduleBody = document.getElementById('pipelineCardBody');
|
|||
|
|
const collapseIcon = document.getElementById('pipelineCollapseIcon');
|
|||
|
|
if (!moduleBody) return;
|
|||
|
|
|
|||
|
|
const shouldOpen = forceOpen === null ? moduleBody.classList.contains('d-none') : Boolean(forceOpen);
|
|||
|
|
if (shouldOpen) {
|
|||
|
|
moduleBody.classList.remove('d-none');
|
|||
|
|
if (collapseIcon) {
|
|||
|
|
collapseIcon.classList.remove('bi-chevron-down');
|
|||
|
|
collapseIcon.classList.add('bi-chevron-up');
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
moduleBody.classList.add('d-none');
|
|||
|
|
if (collapseIcon) {
|
|||
|
|
collapseIcon.classList.remove('bi-chevron-up');
|
|||
|
|
collapseIcon.classList.add('bi-chevron-down');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function ensurePipelineStagesLoaded() {
|
|||
|
|
const select = document.getElementById('pipelineStageSelect');
|
|||
|
|
if (!select) return;
|
|||
|
|
|
|||
|
|
if (select.options.length > 1) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
|
|||
|
|
if (!response.ok) return;
|
|||
|
|
|
|||
|
|
const stages = await response.json();
|
|||
|
|
if (!Array.isArray(stages) || stages.length === 0) return;
|
|||
|
|
|
|||
|
|
const existingValue = select.value || '';
|
|||
|
|
select.innerHTML = '<option value="">Ikke sat</option>' +
|
|||
|
|
stages.map((stage) => `<option value="${stage.id}">${stage.name}</option>`).join('');
|
|||
|
|
if (existingValue) {
|
|||
|
|
select.value = existingValue;
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Could not load pipeline stages', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveCaseType(newType, newLabel, newIcon, newColor) {
|
|||
|
|
// Update UI immediately for snappy feel
|
|||
|
|
const btn = document.getElementById('caseTypeDropdownBtn');
|
|||
|
|
const lbl = document.getElementById('caseTypeLabel');
|
|||
|
|
const ico = document.getElementById('caseTypeIcon');
|
|||
|
|
if (btn) btn.style.setProperty('--tcolor', newColor);
|
|||
|
|
if (lbl) lbl.textContent = newLabel;
|
|||
|
|
if (ico) { ico.className = 'bi ' + newIcon; }
|
|||
|
|
window.caseTypeKey = String(newType || 'ticket').toLowerCase();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch(`/api/v1/sag/${caseId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ type: newType })
|
|||
|
|
});
|
|||
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|||
|
|
// Reload to re-render template vars (color accent on ID chip etc.)
|
|||
|
|
location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('saveCaseType error', e);
|
|||
|
|
showToast('Kunne ikke gemme sagstype', 'danger');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveCaseStatusFromTopbar() {
|
|||
|
|
const select = document.getElementById('topbarStatusSelect');
|
|||
|
|
if (!select) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ status: select.value || 'åben' })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|||
|
|
location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('saveCaseStatusFromTopbar error', e);
|
|||
|
|
showToast('Kunne ikke gemme status', 'danger');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function hydrateTopbarStatusOptions() {
|
|||
|
|
const select = document.getElementById('topbarStatusSelect');
|
|||
|
|
if (!select) return;
|
|||
|
|
|
|||
|
|
const initialValue = String(select.value || '').trim();
|
|||
|
|
const known = new Map();
|
|||
|
|
|
|||
|
|
const addStatus = (raw) => {
|
|||
|
|
const value = String(raw || '').trim();
|
|||
|
|
if (!value) return;
|
|||
|
|
const key = value.toLowerCase();
|
|||
|
|
if (!known.has(key)) {
|
|||
|
|
known.set(key, value);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
Array.from(select.options || []).forEach((opt) => addStatus(opt.value));
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/sag?include_deferred=true', { credentials: 'include' });
|
|||
|
|
if (response.ok) {
|
|||
|
|
const cases = await response.json();
|
|||
|
|
(Array.isArray(cases) ? cases : []).forEach((c) => addStatus(c?.status));
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Could not hydrate status options from cases API', error);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
['åben', 'under behandling', 'afventer', 'løst', 'lukket'].forEach(addStatus);
|
|||
|
|
addStatus(initialValue);
|
|||
|
|
|
|||
|
|
const sortedValues = Array.from(known.values()).sort((a, b) =>
|
|||
|
|
a.localeCompare(b, 'da', { sensitivity: 'base' })
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
select.innerHTML = sortedValues.map((value) => {
|
|||
|
|
const selected = initialValue && value.toLowerCase() === initialValue.toLowerCase();
|
|||
|
|
return `<option value="${value}" ${selected ? 'selected' : ''}>${value.charAt(0).toUpperCase()}${value.slice(1)}</option>`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
if (initialValue) {
|
|||
|
|
select.value = Array.from(known.values()).find((v) => v.toLowerCase() === initialValue.toLowerCase()) || initialValue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
applyTopbarImportanceBubbles();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveCaseTypeFromTopbar() {
|
|||
|
|
const select = document.getElementById('topbarTypeSelect');
|
|||
|
|
if (!select) return;
|
|||
|
|
|
|||
|
|
const typeMeta = {
|
|||
|
|
ticket: { label: 'Ticket', icon: 'bi-ticket-perforated', color: '#6366f1' },
|
|||
|
|
pipeline: { label: 'Pipeline', icon: 'bi-graph-up-arrow', color: '#0ea5e9' },
|
|||
|
|
opgave: { label: 'Opgave', icon: 'bi-puzzle', color: '#f59e0b' },
|
|||
|
|
ordre: { label: 'Ordre', icon: 'bi-receipt', color: '#10b981' },
|
|||
|
|
projekt: { label: 'Projekt', icon: 'bi-folder2-open', color: '#8b5cf6' },
|
|||
|
|
service: { label: 'Service', icon: 'bi-tools', color: '#ef4444' }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const nextType = (select.value || 'ticket').toLowerCase();
|
|||
|
|
const meta = typeMeta[nextType] || typeMeta.ticket;
|
|||
|
|
saveCaseType(nextType, meta.label, meta.icon, meta.color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveCasePriorityFromTopbar() {
|
|||
|
|
const select = document.getElementById('topbarPrioritySelect');
|
|||
|
|
if (!select) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch(`/api/v1/sag/${caseId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ priority: (select.value || 'normal').toLowerCase() })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|||
|
|
location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('saveCasePriorityFromTopbar error', e);
|
|||
|
|
showToast('Kunne ikke gemme prioritet', 'danger');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveCaseStartDateFromTopbar() {
|
|||
|
|
const input = document.getElementById('topbarStartDateInput');
|
|||
|
|
if (!input) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch(`/api/v1/sag/${caseId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ start_date: input.value || null })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|||
|
|
location.reload();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('saveCaseStartDateFromTopbar error', e);
|
|||
|
|
showToast('Kunne ikke gemme startdato', 'danger');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearCaseStartDateFromTopbar() {
|
|||
|
|
const input = document.getElementById('topbarStartDateInput');
|
|||
|
|
if (!input) return;
|
|||
|
|
input.value = '';
|
|||
|
|
saveCaseStartDateFromTopbar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setStartDateAndSave(offsetDays = 0) {
|
|||
|
|
const input = document.getElementById('topbarStartDateInput');
|
|||
|
|
if (!input) return;
|
|||
|
|
const base = new Date();
|
|||
|
|
base.setDate(base.getDate() + Number(offsetDays || 0));
|
|||
|
|
input.value = base.toISOString().slice(0, 10);
|
|||
|
|
saveCaseStartDateFromTopbar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openStartDateModal() {
|
|||
|
|
const topbarInput = document.getElementById('topbarStartDateInput');
|
|||
|
|
const modalInput = document.getElementById('startDateModalInput');
|
|||
|
|
if (topbarInput && modalInput) {
|
|||
|
|
modalInput.value = topbarInput.value || '';
|
|||
|
|
}
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('startDateModal'));
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shiftStartDateDays(days) {
|
|||
|
|
const input = document.getElementById('startDateModalInput');
|
|||
|
|
if (!input) return;
|
|||
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|||
|
|
base.setDate(base.getDate() + days);
|
|||
|
|
input.value = base.toISOString().slice(0, 10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shiftStartDateMonths(months) {
|
|||
|
|
const input = document.getElementById('startDateModalInput');
|
|||
|
|
if (!input) return;
|
|||
|
|
const base = input.value ? new Date(input.value) : new Date();
|
|||
|
|
base.setMonth(base.getMonth() + months);
|
|||
|
|
input.value = base.toISOString().slice(0, 10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setStartDateModalToday() {
|
|||
|
|
const input = document.getElementById('startDateModalInput');
|
|||
|
|
if (!input) return;
|
|||
|
|
input.value = new Date().toISOString().slice(0, 10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearStartDateFromModal() {
|
|||
|
|
const topbarInput = document.getElementById('topbarStartDateInput');
|
|||
|
|
const modalInput = document.getElementById('startDateModalInput');
|
|||
|
|
if (topbarInput) topbarInput.value = '';
|
|||
|
|
if (modalInput) modalInput.value = '';
|
|||
|
|
saveCaseStartDateFromTopbar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveStartDateFromModal() {
|
|||
|
|
const topbarInput = document.getElementById('topbarStartDateInput');
|
|||
|
|
const modalInput = document.getElementById('startDateModalInput');
|
|||
|
|
if (!topbarInput || !modalInput) return;
|
|||
|
|
topbarInput.value = modalInput.value || '';
|
|||
|
|
saveCaseStartDateFromTopbar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveAssignmentFromTabsBar() {
|
|||
|
|
const topUser = document.getElementById('tabsAssignmentUserSelect');
|
|||
|
|
const topGroup = document.getElementById('tabsAssignmentGroupSelect');
|
|||
|
|
const legacyUser = document.getElementById('assignmentUserSelect');
|
|||
|
|
const legacyGroup = document.getElementById('assignmentGroupSelect');
|
|||
|
|
|
|||
|
|
if (legacyUser && topUser) {
|
|||
|
|
legacyUser.value = topUser.value;
|
|||
|
|
}
|
|||
|
|
if (legacyGroup && topGroup) {
|
|||
|
|
legacyGroup.value = topGroup.value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await saveAssignment();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveAssignment() {
|
|||
|
|
const statusEl = document.getElementById('assignmentStatus');
|
|||
|
|
const userValue = document.getElementById('assignmentUserSelect')?.value || '';
|
|||
|
|
const groupValue = document.getElementById('assignmentGroupSelect')?.value || '';
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
ansvarlig_bruger_id: userValue ? parseInt(userValue, 10) : null,
|
|||
|
|
assigned_group_id: groupValue ? parseInt(groupValue, 10) : null
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (statusEl) {
|
|||
|
|
statusEl.textContent = 'Gemmer...';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
let message = 'Kunne ikke gemme tildeling';
|
|||
|
|
try {
|
|||
|
|
const data = await response.json();
|
|||
|
|
message = data.detail || message;
|
|||
|
|
} catch (err) {
|
|||
|
|
// Keep default message
|
|||
|
|
}
|
|||
|
|
if (statusEl) {
|
|||
|
|
statusEl.textContent = `❌ ${message}`;
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (statusEl) {
|
|||
|
|
statusEl.textContent = '✅ Tildeling gemt';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const topUser = document.getElementById('tabsAssignmentUserSelect');
|
|||
|
|
const topGroup = document.getElementById('tabsAssignmentGroupSelect');
|
|||
|
|
if (topUser) {
|
|||
|
|
topUser.value = userValue;
|
|||
|
|
}
|
|||
|
|
if (topGroup) {
|
|||
|
|
topGroup.value = groupValue;
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
if (statusEl) {
|
|||
|
|
statusEl.textContent = `❌ ${err.message}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openAssignmentQuick() {
|
|||
|
|
const preferred = document.getElementById('tabsAssignmentUserSelect')
|
|||
|
|
|| document.getElementById('assignmentUserSelect');
|
|||
|
|
const fallback = document.getElementById('tabsAssignmentGroupSelect')
|
|||
|
|
|| document.getElementById('assignmentGroupSelect');
|
|||
|
|
const target = preferred || fallback;
|
|||
|
|
|
|||
|
|
if (!target) {
|
|||
|
|
alert('Kunne ikke finde tildelingsfelter på siden.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|||
|
|
setTimeout(() => target.focus(), 200);
|
|||
|
|
|
|||
|
|
if (typeof showToast === 'function') {
|
|||
|
|
showToast('Vælg ansvarlig bruger/gruppe i feltet, så gemmes det med det samme.', 'info');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function savePipeline() {
|
|||
|
|
const stageValue = document.getElementById('pipelineStageSelect').value;
|
|||
|
|
const probabilityValue = document.getElementById('pipelineProbabilityInput').value;
|
|||
|
|
const amountValue = document.getElementById('pipelineAmountInput').value;
|
|||
|
|
const descriptionValue = document.getElementById('pipelineDescriptionInput').value;
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
stage_id: stageValue ? parseInt(stageValue, 10) : null,
|
|||
|
|
probability: probabilityValue === '' ? null : parseInt(probabilityValue, 10),
|
|||
|
|
amount: amountValue === '' ? null : parseFloat(amountValue),
|
|||
|
|
description: descriptionValue === '' ? null : descriptionValue
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/sag/${caseId}/pipeline`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
let message = 'Kunne ikke opdatere pipeline';
|
|||
|
|
try {
|
|||
|
|
const err = await response.json();
|
|||
|
|
message = err.detail || err.message || message;
|
|||
|
|
} catch (_e) {
|
|||
|
|
const text = await response.text();
|
|||
|
|
if (text) message = text;
|
|||
|
|
}
|
|||
|
|
throw new Error(`${message} (HTTP ${response.status})`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert(`Fejl: ${error.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==========================================
|
|||
|
|
// VIEW CONTROL (Tag-based)
|
|||
|
|
// ==========================================
|
|||
|
|
|
|||
|
|
let modulePrefs = {};
|
|||
|
|
let currentCaseView = 'Sag-detalje';
|
|||
|
|
|
|||
|
|
function moduleHasContent(el) {
|
|||
|
|
const attr = el.getAttribute('data-has-content');
|
|||
|
|
if (attr === 'true') return true;
|
|||
|
|
if (attr === 'false') return false;
|
|||
|
|
if (attr === 'unknown') return false;
|
|||
|
|
|
|||
|
|
if (el.querySelector('.person-card')) return true;
|
|||
|
|
if (el.querySelector('.list-group-item')) return true;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setModuleContentState(moduleKey, hasContent) {
|
|||
|
|
const el = document.querySelector(`[data-module="${moduleKey}"]`);
|
|||
|
|
if (!el) return;
|
|||
|
|
el.setAttribute('data-has-content', hasContent ? 'true' : 'false');
|
|||
|
|
applyViewLayout(currentCaseView);
|
|||
|
|
updateCaseTabCountBadges();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyViewLayout(viewName) {
|
|||
|
|
if (!viewName) return;
|
|||
|
|
currentCaseView = viewName;
|
|||
|
|
document.body.setAttribute('data-case-view', viewName);
|
|||
|
|
|
|||
|
|
const viewDefaults = {
|
|||
|
|
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
|
|||
|
|
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'],
|
|||
|
|
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const currentCaseTypeKey = (typeof caseTypeKey !== 'undefined' && caseTypeKey)
|
|||
|
|
? caseTypeKey
|
|||
|
|
: (window.caseTypeKey || 'ticket');
|
|||
|
|
const defaultsByCaseType = caseTypeModuleDefaults[currentCaseTypeKey];
|
|||
|
|
const standardModules = Array.isArray(defaultsByCaseType) && defaultsByCaseType.length > 0
|
|||
|
|
? defaultsByCaseType
|
|||
|
|
: (viewDefaults[viewName] || []);
|
|||
|
|
const standardModuleSet = new Set(standardModules);
|
|||
|
|
standardModuleSet.add('tags');
|
|||
|
|
standardModuleSet.add('time');
|
|||
|
|
|
|||
|
|
document.querySelectorAll('[data-module]').forEach((el) => {
|
|||
|
|
const moduleName = el.getAttribute('data-module');
|
|||
|
|
const hasContent = moduleHasContent(el);
|
|||
|
|
const isTimeModule = moduleName === 'time';
|
|||
|
|
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
|
|||
|
|
const pref = modulePrefs[moduleName];
|
|||
|
|
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
|
|||
|
|
|
|||
|
|
// Helper til at skjule eller vise modulet og dets mb-3 indpakning
|
|||
|
|
const setVisibility = (visible) => {
|
|||
|
|
let wrapper = null;
|
|||
|
|
if (el.parentElement) {
|
|||
|
|
const isMB3 = el.parentElement.classList.contains('mb-3');
|
|||
|
|
const isRowCol12 = el.parentElement.classList.contains('col-12') && el.parentElement.parentElement && el.parentElement.parentElement.classList.contains('row');
|
|||
|
|
if (isMB3) wrapper = el.parentElement;
|
|||
|
|
else if (isRowCol12) wrapper = el.parentElement.parentElement;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (visible) {
|
|||
|
|
el.classList.remove('d-none');
|
|||
|
|
if (wrapper && wrapper.classList.contains('d-none')) {
|
|||
|
|
wrapper.classList.remove('d-none');
|
|||
|
|
}
|
|||
|
|
if (tabButton && tabButton.classList.contains('d-none')) {
|
|||
|
|
tabButton.classList.remove('d-none');
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
el.classList.add('d-none');
|
|||
|
|
if (wrapper && !wrapper.classList.contains('d-none')) wrapper.classList.add('d-none');
|
|||
|
|
if (tabButton && !tabButton.classList.contains('d-none')) tabButton.classList.add('d-none');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Altid vis time (tid)
|
|||
|
|
if (isTimeModule) {
|
|||
|
|
setVisibility(true);
|
|||
|
|
el.classList.remove('module-empty-compact');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
|
|||
|
|
if (pref === false) {
|
|||
|
|
setVisibility(false);
|
|||
|
|
el.classList.remove('module-empty-compact');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// HVIS specifik præference aktiverer den (brugervalg)
|
|||
|
|
if (pref === true) {
|
|||
|
|
setVisibility(true);
|
|||
|
|
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty && !hasContent);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Moduler med indhold vises automatisk, når der ikke er et eksplicit brugervalg.
|
|||
|
|
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);
|
|||
|
|
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty);
|
|||
|
|
} else {
|
|||
|
|
setVisibility(false);
|
|||
|
|
el.classList.remove('module-empty-compact');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
updateRightColumnVisibility();
|
|||
|
|
updateInnerColumnVisibility();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateRightColumnVisibility() {
|
|||
|
|
const rightColumn = document.getElementById('case-right-column');
|
|||
|
|
const leftColumn = document.getElementById('case-left-column');
|
|||
|
|
if (!rightColumn || !leftColumn) return;
|
|||
|
|
|
|||
|
|
const visibleRightModules = rightColumn.querySelectorAll('.right-module-card:not(.d-none)');
|
|||
|
|
if (visibleRightModules.length === 0) {
|
|||
|
|
rightColumn.classList.add('d-none');
|
|||
|
|
rightColumn.classList.remove('col-xl-4');
|
|||
|
|
rightColumn.classList.remove('col-lg-4');
|
|||
|
|
leftColumn.classList.remove('col-xl-8');
|
|||
|
|
leftColumn.classList.remove('col-lg-8');
|
|||
|
|
leftColumn.classList.add('col-12');
|
|||
|
|
} else {
|
|||
|
|
rightColumn.classList.remove('d-none');
|
|||
|
|
rightColumn.classList.add('col-xl-4');
|
|||
|
|
rightColumn.classList.add('col-lg-4');
|
|||
|
|
leftColumn.classList.add('col-xl-8');
|
|||
|
|
leftColumn.classList.add('col-lg-8');
|
|||
|
|
leftColumn.classList.remove('col-12');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateInnerColumnVisibility() {
|
|||
|
|
const leftCol = document.getElementById('inner-left-col');
|
|||
|
|
const centerCol = document.getElementById('inner-center-col');
|
|||
|
|
if (!leftCol || !centerCol) return;
|
|||
|
|
|
|||
|
|
// Tæl synlige moduler i venstre kolonnen (mb-3 wrappers der ikke er skjulte)
|
|||
|
|
const visibleLeftModules = leftCol.querySelectorAll('.mb-3:not(.d-none) [data-module]');
|
|||
|
|
const hasVisibleLeft = visibleLeftModules.length > 0;
|
|||
|
|
|
|||
|
|
if (!hasVisibleLeft) {
|
|||
|
|
// Ingen synlige moduler i venstre - center forbliver fuld bredde
|
|||
|
|
leftCol.classList.add('d-none');
|
|||
|
|
centerCol.classList.add('col-12');
|
|||
|
|
} else {
|
|||
|
|
// Begge interne sektioner vises stadig i én kolonne hver
|
|||
|
|
leftCol.classList.remove('d-none');
|
|||
|
|
centerCol.classList.add('col-12');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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}`);
|
|||
|
|
if (!res.ok) return;
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadModulePrefs() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/modules`);
|
|||
|
|
if (!res.ok) return;
|
|||
|
|
const prefs = await res.json();
|
|||
|
|
modulePrefs = (prefs || []).reduce((acc, p) => {
|
|||
|
|
acc[p.module_key] = p.is_enabled;
|
|||
|
|
return acc;
|
|||
|
|
}, {});
|
|||
|
|
modulePrefs.time = true;
|
|||
|
|
updateCaseTabCountBadges();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Module prefs load failed', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadCaseTypeModuleDefaultsSetting() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/settings/case_type_module_defaults');
|
|||
|
|
if (!res.ok) return;
|
|||
|
|
const setting = await res.json();
|
|||
|
|
const parsed = JSON.parse(setting.value || '{}');
|
|||
|
|
if (parsed && typeof parsed === 'object') {
|
|||
|
|
caseTypeModuleDefaults = Object.entries(parsed).reduce((acc, [key, value]) => {
|
|||
|
|
acc[String(key || '').toLowerCase()] = Array.isArray(value) ? value : [];
|
|||
|
|
return acc;
|
|||
|
|
}, {});
|
|||
|
|
} else {
|
|||
|
|
caseTypeModuleDefaults = {};
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Case type module defaults load failed', e);
|
|||
|
|
caseTypeModuleDefaults = {};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openModuleControlModal() {
|
|||
|
|
const list = document.getElementById('moduleControlList');
|
|||
|
|
list.innerHTML = '<div class="text-muted small">Indlæser...</div>';
|
|||
|
|
|
|||
|
|
const modules = Array.from(document.querySelectorAll('[data-module]')).map(el => {
|
|||
|
|
const key = el.getAttribute('data-module');
|
|||
|
|
return { key, label: window.moduleDisplayNames[key] || key };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
list.innerHTML = modules.map(m => {
|
|||
|
|
const isTimeModule = m.key === 'time';
|
|||
|
|
const checked = isTimeModule ? true : modulePrefs[m.key] !== false;
|
|||
|
|
return `
|
|||
|
|
<div class="form-check mb-2">
|
|||
|
|
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
|
|||
|
|
${isTimeModule ? 'disabled' : ''}
|
|||
|
|
onchange="toggleModulePref('${m.key}', this.checked)">
|
|||
|
|
<label class="form-check-label" for="module_${m.key}">${m.label}${isTimeModule ? ' (altid synlig)' : ''}</label>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('moduleControlModal'));
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleModulePref(moduleKey, isEnabled) {
|
|||
|
|
if (moduleKey === 'time') {
|
|||
|
|
modulePrefs.time = true;
|
|||
|
|
applyViewFromTags();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/modules`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({ module_key: moduleKey, is_enabled: isEnabled })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const err = await res.json();
|
|||
|
|
throw new Error(err.detail || 'Kunne ikke opdatere modul');
|
|||
|
|
}
|
|||
|
|
modulePrefs[moduleKey] = isEnabled;
|
|||
|
|
applyViewFromTags();
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Fejl: ' + e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==========================================
|
|||
|
|
// FILES & EMAILS LOGIC
|
|||
|
|
// ==========================================
|
|||
|
|
|
|||
|
|
let sagFilesCache = [];
|
|||
|
|
|
|||
|
|
// ---------------- FILES ----------------
|
|||
|
|
|
|||
|
|
function updateCaseEmailAttachmentOptions(files) {
|
|||
|
|
const select = document.getElementById('caseEmailAttachmentIds');
|
|||
|
|
if (!select) return;
|
|||
|
|
|
|||
|
|
const safeFiles = Array.isArray(files) ? files : [];
|
|||
|
|
if (!safeFiles.length) {
|
|||
|
|
select.innerHTML = '<option disabled>Ingen sagsfiler tilgængelige</option>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
select.innerHTML = safeFiles.map((file) => {
|
|||
|
|
const fileId = Number(file.id);
|
|||
|
|
const filename = escapeHtml(file.filename || `Fil ${fileId}`);
|
|||
|
|
const date = file.created_at ? new Date(file.created_at).toLocaleDateString('da-DK') : '-';
|
|||
|
|
return `<option value="${fileId}">${filename} (${date})</option>`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadSagFiles() {
|
|||
|
|
const container = document.getElementById('files-list');
|
|||
|
|
if (container) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Henter filer...</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/files`);
|
|||
|
|
if(res.ok) {
|
|||
|
|
const files = await res.json();
|
|||
|
|
sagFilesCache = Array.isArray(files) ? files : [];
|
|||
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|||
|
|
renderFiles(files);
|
|||
|
|
} else {
|
|||
|
|
sagFilesCache = [];
|
|||
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|||
|
|
if (container) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
|
|||
|
|
}
|
|||
|
|
setModuleContentState('files', true);
|
|||
|
|
}
|
|||
|
|
} catch(e) {
|
|||
|
|
console.error(e);
|
|||
|
|
sagFilesCache = [];
|
|||
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|||
|
|
if (container) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
|
|||
|
|
}
|
|||
|
|
setModuleContentState('files', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderFiles(files) {
|
|||
|
|
const container = document.getElementById('files-list');
|
|||
|
|
sagFilesCache = Array.isArray(files) ? files : [];
|
|||
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|||
|
|
|
|||
|
|
if (!container) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if(!files || files.length === 0) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen filer fundet...</div>';
|
|||
|
|
setModuleContentState('files', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setModuleContentState('files', true);
|
|||
|
|
container.innerHTML = files.map(f => {
|
|||
|
|
const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB';
|
|||
|
|
const sourceType = String(f.source_type || '').toLowerCase();
|
|||
|
|
const sourceToken = String(f.source_token || '').toUpperCase();
|
|||
|
|
const isWorkOrder = sourceType === 'scanner_email' && sourceToken.includes('BMCSCAN-WO-');
|
|||
|
|
const isScannerFile = sourceType === 'scanner_email';
|
|||
|
|
const displayName = isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename;
|
|||
|
|
const badgeHtml = isWorkOrder
|
|||
|
|
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
|
|||
|
|
: (isScannerFile
|
|||
|
|
? '<span class="badge rounded-pill text-bg-info ms-2"><i class="bi bi-upc-scan me-1"></i>Scanner</span>'
|
|||
|
|
: '');
|
|||
|
|
return `
|
|||
|
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
|||
|
|
<div class="ms-2 me-auto">
|
|||
|
|
<div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;">
|
|||
|
|
<a href="javascript:void(0);" onclick="previewFile(${f.id}, '${f.filename.replace(/'/g, "\\'")}', '${f.content_type || ''}')" class="text-decoration-none text-dark">
|
|||
|
|
<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName}
|
|||
|
|
</a>
|
|||
|
|
${badgeHtml}
|
|||
|
|
</div>
|
|||
|
|
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex gap-1">
|
|||
|
|
<a href="${f.download_url}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
|
|||
|
|
<i class="bi bi-download"></i>
|
|||
|
|
</a>
|
|||
|
|
<button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${f.id})" title="Slet">
|
|||
|
|
<i class="bi bi-x-lg"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleFileUpload(fileList) {
|
|||
|
|
if(!fileList || fileList.length === 0) return;
|
|||
|
|
const formData = new FormData();
|
|||
|
|
for (let i = 0; i < fileList.length; i++) {
|
|||
|
|
formData.append("files", fileList[i]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Show loading
|
|||
|
|
document.getElementById('files-list').innerHTML += '<div class="p-2 text-center text-muted fst-italic">Uploader...</div>';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/files`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: formData
|
|||
|
|
});
|
|||
|
|
if(res.ok) {
|
|||
|
|
loadSagFiles();
|
|||
|
|
} else {
|
|||
|
|
alert('Upload fejlede');
|
|||
|
|
loadSagFiles(); // Reload to clear loading state
|
|||
|
|
}
|
|||
|
|
} catch(e) {
|
|||
|
|
alert('Upload fejl: ' + e);
|
|||
|
|
loadSagFiles();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteFile(fileId) {
|
|||
|
|
if(!confirm("Slet denne fil?")) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/files/${fileId}`, { method: 'DELETE' });
|
|||
|
|
if(res.ok) loadSagFiles();
|
|||
|
|
else alert("Kunne ikke slette fil");
|
|||
|
|
} catch(e) { alert("Fejl: " + e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function renderPdfPreview(fileUrl, fileId, filename, previewContent) {
|
|||
|
|
const imageUrl = `/api/v1/sag/${caseIds}/files/${fileId}/preview-image?page=1&scale=3.4`;
|
|||
|
|
|
|||
|
|
previewContent.innerHTML = `
|
|||
|
|
<div class="d-flex flex-column align-items-center justify-content-center w-100" style="min-height: 70vh;">
|
|||
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|||
|
|
<div class="small text-muted mt-2">Indlæser PDF...</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const img = new Image();
|
|||
|
|
img.alt = filename;
|
|||
|
|
img.className = 'img-fluid';
|
|||
|
|
img.style.maxHeight = '86vh';
|
|||
|
|
img.style.width = 'auto';
|
|||
|
|
img.style.display = 'block';
|
|||
|
|
img.style.margin = '0 auto';
|
|||
|
|
img.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)';
|
|||
|
|
img.style.background = '#fff';
|
|||
|
|
|
|||
|
|
img.onload = () => {
|
|||
|
|
previewContent.innerHTML = '';
|
|||
|
|
|
|||
|
|
const frame = document.createElement('div');
|
|||
|
|
frame.style.background = '#eef2f7';
|
|||
|
|
frame.style.padding = '6px';
|
|||
|
|
frame.style.borderRadius = '12px';
|
|||
|
|
frame.style.minHeight = '82vh';
|
|||
|
|
frame.appendChild(img);
|
|||
|
|
previewContent.appendChild(frame);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
img.onerror = () => {
|
|||
|
|
// Fallback to native viewer in full-window tab if image rendering fails.
|
|||
|
|
window.open(fileUrl, '_blank', 'noopener');
|
|||
|
|
previewContent.innerHTML = `
|
|||
|
|
<div class="p-4 text-center text-muted">
|
|||
|
|
Kunne ikke vise PDF i modal. Åbnede i nyt vindue.
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
img.src = imageUrl;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// File Preview
|
|||
|
|
function previewFile(fileId, filename, contentType) {
|
|||
|
|
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
|
|||
|
|
const previewContent = document.getElementById('previewContent');
|
|||
|
|
const fileNameEl = document.getElementById('previewFileName');
|
|||
|
|
|
|||
|
|
// Set filename
|
|||
|
|
fileNameEl.textContent = filename;
|
|||
|
|
const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`;
|
|||
|
|
|
|||
|
|
// Show loading spinner
|
|||
|
|
previewContent.innerHTML = `
|
|||
|
|
<div class="spinner-border text-primary" role="status">
|
|||
|
|
<span class="visually-hidden">Indlæser...</span>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
modal.show();
|
|||
|
|
|
|||
|
|
// Determine file type and render preview
|
|||
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|||
|
|
|
|||
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(ext)) {
|
|||
|
|
// Image preview
|
|||
|
|
previewContent.innerHTML = `<img src="${fileUrl}" class="img-fluid" style="max-height: 80vh;" alt="${filename}">`;
|
|||
|
|
} else if (ext === 'pdf') {
|
|||
|
|
// PDF preview with forced readable scale
|
|||
|
|
renderPdfPreview(fileUrl, fileId, filename, previewContent);
|
|||
|
|
} else if (['txt', 'log', 'md', 'json', 'xml', 'csv', 'html', 'css', 'js', 'py', 'sql'].includes(ext)) {
|
|||
|
|
// Text file preview
|
|||
|
|
fetch(fileUrl)
|
|||
|
|
.then(res => res.text())
|
|||
|
|
.then(text => {
|
|||
|
|
previewContent.innerHTML = `<pre class="p-3 m-0 overflow-auto h-100" style="max-height: 80vh;"><code>${escapeHtml(text)}</code></pre>`;
|
|||
|
|
})
|
|||
|
|
.catch(err => {
|
|||
|
|
previewContent.innerHTML = `<div class="alert alert-danger m-4">Kunne ikke indlæse fil: ${err}</div>`;
|
|||
|
|
});
|
|||
|
|
} else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) {
|
|||
|
|
// Office documents - use Google Docs Viewer
|
|||
|
|
const encodedUrl = encodeURIComponent(window.location.origin + fileUrl);
|
|||
|
|
previewContent.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${encodedUrl}&embedded=true" class="w-100 h-100 border-0" style="min-height: 60vh;"></iframe>`;
|
|||
|
|
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
|
|||
|
|
// Video preview
|
|||
|
|
previewContent.innerHTML = `
|
|||
|
|
<video controls class="w-100" style="max-height: 80vh;">
|
|||
|
|
<source src="${fileUrl}" type="video/${ext}">
|
|||
|
|
Din browser understøtter ikke video afspilning.
|
|||
|
|
</video>
|
|||
|
|
`;
|
|||
|
|
} else if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) {
|
|||
|
|
// Audio preview
|
|||
|
|
previewContent.innerHTML = `
|
|||
|
|
<div class="p-5 text-center">
|
|||
|
|
<i class="bi bi-music-note-beamed display-1 text-muted mb-4"></i>
|
|||
|
|
<h5>${filename}</h5>
|
|||
|
|
<audio controls class="w-100 mt-3">
|
|||
|
|
<source src="${fileUrl}" type="audio/${ext}">
|
|||
|
|
Din browser understøtter ikke audio afspilning.
|
|||
|
|
</audio>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else {
|
|||
|
|
// Unsupported file type
|
|||
|
|
previewContent.innerHTML = `
|
|||
|
|
<div class="p-5 text-center">
|
|||
|
|
<i class="bi bi-file-earmark-x display-1 text-muted mb-4"></i>
|
|||
|
|
<h5>Kan ikke vise forhåndsvisning for denne filtype</h5>
|
|||
|
|
<p class="text-muted">${filename}</p>
|
|||
|
|
<a href="${fileUrl}?download=true" class="btn btn-primary mt-3" download="${filename}">
|
|||
|
|
<i class="bi bi-download me-2"></i> Download fil
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeHtml(text) {
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.textContent = text;
|
|||
|
|
return div.innerHTML;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// File Drag & Drop
|
|||
|
|
const fileDropZone = document.getElementById('fileDropZone');
|
|||
|
|
if(fileDropZone) {
|
|||
|
|
fileDropZone.addEventListener('dragover', e => { e.preventDefault(); fileDropZone.classList.add('bg-light-subtle'); });
|
|||
|
|
fileDropZone.addEventListener('dragleave', e => { e.preventDefault(); fileDropZone.classList.remove('bg-light-subtle'); });
|
|||
|
|
fileDropZone.addEventListener('drop', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
fileDropZone.classList.remove('bg-light-subtle');
|
|||
|
|
if(e.dataTransfer.files.length) handleFileUpload(e.dataTransfer.files);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------- EMAILS ----------------
|
|||
|
|
|
|||
|
|
let linkedEmailsCache = [];
|
|||
|
|
let filteredLinkedEmailsCache = [];
|
|||
|
|
let selectedLinkedEmailId = null;
|
|||
|
|
let isNewThread = false; // true when composing a brand-new thread (not a reply)
|
|||
|
|
let selectedLinkedEmailDetail = null;
|
|||
|
|
let selectedEmailThreadKey = null;
|
|||
|
|
|
|||
|
|
function parseEmailField(value) {
|
|||
|
|
return String(value || '')
|
|||
|
|
.split(/[\n,;]+/)
|
|||
|
|
.map((email) => email.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeHtmlForInput(value) {
|
|||
|
|
return String(value || '')
|
|||
|
|
.replace(/&/g, '&')
|
|||
|
|
.replace(/</g, '<')
|
|||
|
|
.replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
.replace(/'/g, ''');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let rewriteReviewState = null;
|
|||
|
|
|
|||
|
|
function extractRewriteBody(rawText, context) {
|
|||
|
|
const text = String(rawText || '').trim();
|
|||
|
|
if (!text) return '';
|
|||
|
|
|
|||
|
|
if (context === 'email') {
|
|||
|
|
const bodyMatch = text.match(/(?:^|\n)Besked:\s*\n([\s\S]*)$/i);
|
|||
|
|
if (bodyMatch?.[1]) return bodyMatch[1].trim();
|
|||
|
|
return text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (context === 'case') {
|
|||
|
|
const descMatch = text.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i);
|
|||
|
|
if (descMatch?.[1]) return descMatch[1].trim();
|
|||
|
|
return text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildLineDiff(originalText, rewrittenText) {
|
|||
|
|
const originalLines = String(originalText || '').split('\n');
|
|||
|
|
const rewrittenLines = String(rewrittenText || '').split('\n');
|
|||
|
|
const maxLen = Math.max(originalLines.length, rewrittenLines.length);
|
|||
|
|
const changes = [];
|
|||
|
|
|
|||
|
|
for (let idx = 0; idx < maxLen; idx += 1) {
|
|||
|
|
const before = originalLines[idx] ?? '';
|
|||
|
|
const after = rewrittenLines[idx] ?? '';
|
|||
|
|
if (before !== after) {
|
|||
|
|
changes.push({ index: idx, before, after });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { changes, originalLines, rewrittenLines };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateRewriteSelectionInfo() {
|
|||
|
|
const infoEl = document.getElementById('rewritePreviewSelectionInfo');
|
|||
|
|
const selectedCount = document.querySelectorAll('.rewrite-change-check:checked').length;
|
|||
|
|
const totalCount = rewriteReviewState?.changes?.length || 0;
|
|||
|
|
if (!infoEl) return;
|
|||
|
|
infoEl.textContent = `${selectedCount} af ${totalCount} ændringer valgt`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderRewritePreview(changes) {
|
|||
|
|
const listEl = document.getElementById('rewritePreviewList');
|
|||
|
|
const noChangesEl = document.getElementById('rewritePreviewNoChanges');
|
|||
|
|
if (!listEl || !noChangesEl) return;
|
|||
|
|
|
|||
|
|
if (!changes.length) {
|
|||
|
|
listEl.innerHTML = '';
|
|||
|
|
noChangesEl.classList.remove('d-none');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
noChangesEl.classList.add('d-none');
|
|||
|
|
listEl.innerHTML = changes.map((change, i) => `
|
|||
|
|
<div class="card border-0 shadow-sm">
|
|||
|
|
<div class="card-body py-2 px-3">
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|||
|
|
<div class="form-check">
|
|||
|
|
<input class="form-check-input rewrite-change-check" type="checkbox" value="${change.index}" id="rewriteChange_${change.index}" checked>
|
|||
|
|
<label class="form-check-label small fw-semibold" for="rewriteChange_${change.index}">
|
|||
|
|
Ændring ${i + 1} (linje ${change.index + 1})
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="row g-2">
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<div class="small text-muted mb-1">Før</div>
|
|||
|
|
<div class="border rounded p-2 bg-light" style="white-space: pre-wrap; min-height: 44px;">${escapeHtml(change.before) || '<span class="text-muted fst-italic">(tom)</span>'}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-md-6">
|
|||
|
|
<div class="small text-muted mb-1">Efter</div>
|
|||
|
|
<div class="border rounded p-2" style="white-space: pre-wrap; min-height: 44px; background: rgba(15,76,117,0.08);">${escapeHtml(change.after) || '<span class="text-muted fst-italic">(tom)</span>'}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
|
|||
|
|
listEl.querySelectorAll('.rewrite-change-check').forEach((input) => {
|
|||
|
|
input.addEventListener('change', updateRewriteSelectionInfo);
|
|||
|
|
});
|
|||
|
|
updateRewriteSelectionInfo();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyRewriteChanges(mode) {
|
|||
|
|
if (!rewriteReviewState) return;
|
|||
|
|
|
|||
|
|
const { originalLines, rewrittenLines, applyToTarget } = rewriteReviewState;
|
|||
|
|
if (mode === 'all') {
|
|||
|
|
applyToTarget(rewrittenLines.join('\n'));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const selectedIndexes = new Set(
|
|||
|
|
Array.from(document.querySelectorAll('.rewrite-change-check:checked'))
|
|||
|
|
.map((el) => Number(el.value))
|
|||
|
|
.filter((val) => Number.isInteger(val) && val >= 0)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const merged = [...originalLines];
|
|||
|
|
for (let idx = 0; idx < rewrittenLines.length; idx += 1) {
|
|||
|
|
if (selectedIndexes.has(idx)) {
|
|||
|
|
merged[idx] = rewrittenLines[idx] ?? '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
applyToTarget(merged.join('\n'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openRewriteReviewModal({ title, originalText, rewrittenText, applyToTarget }) {
|
|||
|
|
const summaryEl = document.getElementById('rewritePreviewSummary');
|
|||
|
|
const applyAllBtn = document.getElementById('rewriteApplyAllBtn');
|
|||
|
|
const applySelectedBtn = document.getElementById('rewriteApplySelectedBtn');
|
|||
|
|
const modalEl = document.getElementById('rewritePreviewModal');
|
|||
|
|
if (!summaryEl || !applyAllBtn || !applySelectedBtn || !modalEl) return;
|
|||
|
|
|
|||
|
|
const diff = buildLineDiff(originalText, rewrittenText);
|
|||
|
|
rewriteReviewState = {
|
|||
|
|
...diff,
|
|||
|
|
applyToTarget,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
summaryEl.textContent = `${title}: ${diff.changes.length} foreslaaede ændringer.`;
|
|||
|
|
renderRewritePreview(diff.changes);
|
|||
|
|
|
|||
|
|
applyAllBtn.disabled = !diff.changes.length;
|
|||
|
|
applySelectedBtn.disabled = !diff.changes.length;
|
|||
|
|
|
|||
|
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|||
|
|
modal.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function requestRewriteSuggestion(endpoint, text, context) {
|
|||
|
|
const response = await fetch(endpoint, {
|
|||
|
|
method: 'POST',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ text, context })
|
|||
|
|
});
|
|||
|
|
if (!response.ok) {
|
|||
|
|
let detail = `HTTP ${response.status}`;
|
|||
|
|
try {
|
|||
|
|
const err = await response.json();
|
|||
|
|
if (err?.detail) detail = err.detail;
|
|||
|
|
} catch (_) {}
|
|||
|
|
throw new Error(detail);
|
|||
|
|
}
|
|||
|
|
return response.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.rewriteCaseEmailWithApproval = async function () {
|
|||
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|||
|
|
const btn = document.getElementById('caseEmailRewriteBtn');
|
|||
|
|
if (!bodyInput) return;
|
|||
|
|
|
|||
|
|
const source = (bodyInput.value || '').trim();
|
|||
|
|
if (!source) {
|
|||
|
|
alert('Skriv en besked først.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const originalHtml = btn?.innerHTML || '';
|
|||
|
|
if (btn) {
|
|||
|
|
btn.disabled = true;
|
|||
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const payload = await requestRewriteSuggestion('/api/v1/emails/rewrite-text', source, 'email');
|
|||
|
|
const rewritten = extractRewriteBody(payload?.rewritten_text || '', 'email');
|
|||
|
|
openRewriteReviewModal({
|
|||
|
|
title: 'Email-tekst',
|
|||
|
|
originalText: source,
|
|||
|
|
rewrittenText: rewritten,
|
|||
|
|
applyToTarget: (nextText) => {
|
|||
|
|
bodyInput.value = nextText;
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(error);
|
|||
|
|
alert(`Kunne ikke renskrive email: ${error.message || 'Ukendt fejl'}`);
|
|||
|
|
} finally {
|
|||
|
|
if (btn) {
|
|||
|
|
btn.disabled = false;
|
|||
|
|
btn.innerHTML = originalHtml;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function getDefaultCaseRecipient() {
|
|||
|
|
const primaryContact = document.querySelector('.contact-row[data-is-primary="true"][data-email]');
|
|||
|
|
if (primaryContact?.dataset?.email) {
|
|||
|
|
return primaryContact.dataset.email.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const anyContact = document.querySelector('.contact-row[data-email]');
|
|||
|
|
if (anyContact?.dataset?.email) {
|
|||
|
|
return anyContact.dataset.email.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const customerSmall = document.querySelector('.customer-row small');
|
|||
|
|
if (customerSmall) {
|
|||
|
|
const text = customerSmall.textContent || '';
|
|||
|
|
const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
|||
|
|
if (match) {
|
|||
|
|
return match[0].trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function prefillCaseEmailCompose() {
|
|||
|
|
const toInput = document.getElementById('caseEmailTo');
|
|||
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|||
|
|
const modalLabel = document.getElementById('caseEmailComposeModalLabel');
|
|||
|
|
|
|||
|
|
// Update modal title based on context
|
|||
|
|
if (modalLabel) {
|
|||
|
|
modalLabel.innerHTML = isNewThread
|
|||
|
|
? '<i class="bi bi-envelope-plus me-2"></i>Ny email (ny tråd)'
|
|||
|
|
: '<i class="bi bi-envelope me-2"></i>Ny email';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (toInput && !toInput.value.trim()) {
|
|||
|
|
const recipient = getDefaultCaseRecipient();
|
|||
|
|
if (recipient) {
|
|||
|
|
toInput.value = recipient;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (subjectInput && !subjectInput.value.trim()) {
|
|||
|
|
const title = (currentCaseTitle || '').trim() || 'EMNE PÅ SAGEN';
|
|||
|
|
subjectInput.value = escapeHtmlForInput(`(Sag:${caseIds}) - "${title}"`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startNewEmailThread() {
|
|||
|
|
// Clear all compose fields and thread references
|
|||
|
|
selectedLinkedEmailId = null;
|
|||
|
|
isNewThread = true;
|
|||
|
|
const toInput = document.getElementById('caseEmailTo');
|
|||
|
|
const ccInput = document.getElementById('caseEmailCc');
|
|||
|
|
const bccInput = document.getElementById('caseEmailBcc');
|
|||
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|||
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|||
|
|
if (toInput) toInput.value = '';
|
|||
|
|
if (ccInput) ccInput.value = '';
|
|||
|
|
if (bccInput) bccInput.value = '';
|
|||
|
|
if (subjectInput) subjectInput.value = '';
|
|||
|
|
if (bodyInput) bodyInput.value = '';
|
|||
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|||
|
|
if (composeModalEl) {
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openReplyToLinkedEmail() {
|
|||
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|||
|
|
if (!composeModalEl || !selectedLinkedEmailId || !selectedLinkedEmailDetail) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
isNewThread = false; // This is a reply, not a new thread
|
|||
|
|
|
|||
|
|
const toInput = document.getElementById('caseEmailTo');
|
|||
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|||
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|||
|
|
|
|||
|
|
const senderEmail = (selectedLinkedEmailDetail.sender_email || '').trim();
|
|||
|
|
const originalSubject = (selectedLinkedEmailDetail.subject || '').trim();
|
|||
|
|
|
|||
|
|
if (toInput && !toInput.value.trim() && senderEmail) {
|
|||
|
|
toInput.value = senderEmail;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (subjectInput && !subjectInput.value.trim()) {
|
|||
|
|
const replySubject = /^re:\s*/i.test(originalSubject)
|
|||
|
|
? originalSubject
|
|||
|
|
: `Re: ${originalSubject || `Sag #${caseIds}`}`;
|
|||
|
|
subjectInput.value = escapeHtmlForInput(replySubject);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (bodyInput && !bodyInput.value.trim()) {
|
|||
|
|
const received = selectedLinkedEmailDetail.received_date
|
|||
|
|
? new Date(selectedLinkedEmailDetail.received_date).toLocaleString('da-DK')
|
|||
|
|
: '-';
|
|||
|
|
const senderName = selectedLinkedEmailDetail.sender_name || senderEmail || 'Ukendt';
|
|||
|
|
bodyInput.value = `\n\n---\nFra: ${senderName}\nDato: ${received}\nEmne: ${originalSubject || '(Ingen emne)'}\n`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(composeModalEl).show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function sendCaseEmail() {
|
|||
|
|
const toInput = document.getElementById('caseEmailTo');
|
|||
|
|
const ccInput = document.getElementById('caseEmailCc');
|
|||
|
|
const bccInput = document.getElementById('caseEmailBcc');
|
|||
|
|
const subjectInput = document.getElementById('caseEmailSubject');
|
|||
|
|
const bodyInput = document.getElementById('caseEmailBody');
|
|||
|
|
const attachmentSelect = document.getElementById('caseEmailAttachmentIds');
|
|||
|
|
const sendBtn = document.getElementById('caseEmailSendBtn');
|
|||
|
|
const statusEl = document.getElementById('caseEmailSendStatus');
|
|||
|
|
|
|||
|
|
if (!toInput || !subjectInput || !bodyInput || !sendBtn || !statusEl) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const to = parseEmailField(toInput.value);
|
|||
|
|
const cc = parseEmailField(ccInput?.value || '');
|
|||
|
|
const bcc = parseEmailField(bccInput?.value || '');
|
|||
|
|
const subject = (subjectInput.value || '').trim();
|
|||
|
|
const bodyText = (bodyInput.value || '').trim();
|
|||
|
|
const attachmentFileIds = Array.from(attachmentSelect?.selectedOptions || [])
|
|||
|
|
.map((opt) => Number(opt.value))
|
|||
|
|
.filter((id) => Number.isInteger(id) && id > 0);
|
|||
|
|
|
|||
|
|
if (!to.length) {
|
|||
|
|
alert('Udfyld mindst én modtager i Til-feltet.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!subject) {
|
|||
|
|
alert('Udfyld emne før afsendelse.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!bodyText) {
|
|||
|
|
alert('Udfyld besked før afsendelse.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sendBtn.disabled = true;
|
|||
|
|
statusEl.className = 'text-muted';
|
|||
|
|
statusEl.textContent = 'Sender e-mail...';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/emails/send`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
to,
|
|||
|
|
cc,
|
|||
|
|
bcc,
|
|||
|
|
subject,
|
|||
|
|
body_text: bodyText,
|
|||
|
|
attachment_file_ids: attachmentFileIds,
|
|||
|
|
thread_email_id: isNewThread ? null : (selectedLinkedEmailId || null),
|
|||
|
|
thread_key: isNewThread ? null : (
|
|||
|
|
linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.resolved_thread_key
|
|||
|
|
|| linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key
|
|||
|
|
|| null
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`;
|
|||
|
|
try {
|
|||
|
|
const responseText = await res.text();
|
|||
|
|
if (responseText) {
|
|||
|
|
try {
|
|||
|
|
const err = JSON.parse(responseText);
|
|||
|
|
if (err?.detail) {
|
|||
|
|
message = err.detail;
|
|||
|
|
} else if (err?.message) {
|
|||
|
|
message = err.message;
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
message = responseText.slice(0, 500);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
}
|
|||
|
|
throw new Error(message);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (subjectInput) subjectInput.value = '';
|
|||
|
|
if (bodyInput) bodyInput.value = '';
|
|||
|
|
if (ccInput) ccInput.value = '';
|
|||
|
|
if (bccInput) bccInput.value = '';
|
|||
|
|
if (attachmentSelect) {
|
|||
|
|
Array.from(attachmentSelect.options).forEach((option) => {
|
|||
|
|
option.selected = false;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
statusEl.className = 'text-success';
|
|||
|
|
statusEl.textContent = 'E-mail sendt.';
|
|||
|
|
isNewThread = false; // Reset after send
|
|||
|
|
loadLinkedEmails();
|
|||
|
|
|
|||
|
|
const composeModalEl = document.getElementById('caseEmailComposeModal');
|
|||
|
|
const composeModal = composeModalEl ? bootstrap.Modal.getInstance(composeModalEl) : null;
|
|||
|
|
if (composeModal) {
|
|||
|
|
composeModal.hide();
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
statusEl.className = 'text-danger';
|
|||
|
|
statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)';
|
|||
|
|
} finally {
|
|||
|
|
sendBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCaseEmailTab() {
|
|||
|
|
const trigger = document.getElementById('emails-tab');
|
|||
|
|
if (!trigger) return;
|
|||
|
|
const instance = bootstrap.Tab.getOrCreateInstance(trigger);
|
|||
|
|
instance.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.quickReplyToEmailFromComment = async function(emailId) {
|
|||
|
|
const parsedId = Number(emailId);
|
|||
|
|
if (!Number.isFinite(parsedId)) return;
|
|||
|
|
|
|||
|
|
openCaseEmailTab();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await loadLinkedEmails();
|
|||
|
|
await loadLinkedEmailDetail(parsedId);
|
|||
|
|
openReplyToLinkedEmail();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Kunne ikke starte quick svar fra kommentar:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadLinkedEmails() {
|
|||
|
|
const container = document.getElementById('linked-emails-list');
|
|||
|
|
const threadContainer = document.getElementById('email-threads-list');
|
|||
|
|
if (!container || !threadContainer) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`);
|
|||
|
|
if(res.ok) {
|
|||
|
|
linkedEmailsCache = await res.json();
|
|||
|
|
updateEmailTabUnreadBadge();
|
|||
|
|
await applyLinkedEmailFilters(true);
|
|||
|
|
} else {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
|
|||
|
|
threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>';
|
|||
|
|
updateEmailTabUnreadBadge();
|
|||
|
|
setModuleContentState('emails', true);
|
|||
|
|
}
|
|||
|
|
} catch(e) {
|
|||
|
|
console.error(e);
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
|
|||
|
|
threadContainer.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af tråde</div>';
|
|||
|
|
updateEmailTabUnreadBadge();
|
|||
|
|
setModuleContentState('emails', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateEmailTabUnreadBadge() {
|
|||
|
|
const badge = document.getElementById('emailTabUnreadBadge');
|
|||
|
|
if (!badge) return;
|
|||
|
|
|
|||
|
|
const unreadItems = (linkedEmailsCache || []).filter((mail) => !Boolean(mail?.is_read));
|
|||
|
|
if (!unreadItems.length) {
|
|||
|
|
badge.style.display = 'none';
|
|||
|
|
badge.textContent = '';
|
|||
|
|
badge.classList.remove('is-warm', 'is-hot');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const now = Date.now();
|
|||
|
|
const oldestUnreadMs = unreadItems.reduce((oldest, item) => {
|
|||
|
|
const ts = item?.received_date ? new Date(item.received_date).getTime() : now;
|
|||
|
|
return Math.min(oldest, Number.isFinite(ts) ? ts : now);
|
|||
|
|
}, now);
|
|||
|
|
|
|||
|
|
const ageHours = Math.max(0, (now - oldestUnreadMs) / (1000 * 60 * 60));
|
|||
|
|
badge.classList.remove('is-warm', 'is-hot');
|
|||
|
|
if (ageHours >= 72) {
|
|||
|
|
badge.classList.add('is-hot');
|
|||
|
|
} else if (ageHours >= 24) {
|
|||
|
|
badge.classList.add('is-warm');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
badge.style.display = 'inline-block';
|
|||
|
|
badge.textContent = unreadItems.length > 99 ? '99+' : String(unreadItems.length);
|
|||
|
|
badge.title = ageHours >= 72
|
|||
|
|
? 'Ulæste mails (ældre end 72 timer)'
|
|||
|
|
: (ageHours >= 24 ? 'Ulæste mails (ældre end 24 timer)' : 'Ulæste mails');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getFilteredLinkedEmails() {
|
|||
|
|
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
|
|||
|
|
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
|
|||
|
|
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
|
|||
|
|
|
|||
|
|
return linkedEmailsCache.filter((email) => {
|
|||
|
|
if (textFilter) {
|
|||
|
|
const haystack = [
|
|||
|
|
email.subject,
|
|||
|
|
email.sender_email,
|
|||
|
|
email.sender_name,
|
|||
|
|
email.body_text,
|
|||
|
|
email.body_html
|
|||
|
|
].join(' ').toLowerCase();
|
|||
|
|
if (!haystack.includes(textFilter)) return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasAttachments = Boolean(email.has_attachments) || Number(email.attachment_count || 0) > 0;
|
|||
|
|
if (attachmentFilter === 'with' && !hasAttachments) return false;
|
|||
|
|
if (attachmentFilter === 'without' && hasAttachments) return false;
|
|||
|
|
|
|||
|
|
const isRead = Boolean(email.is_read);
|
|||
|
|
if (readFilter === 'read' && !isRead) return false;
|
|||
|
|
if (readFilter === 'unread' && isRead) return false;
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _normalizeThreadToken(value) {
|
|||
|
|
if (!value) return '';
|
|||
|
|
return String(value)
|
|||
|
|
.trim()
|
|||
|
|
.replace(/[<>]/g, '')
|
|||
|
|
.replace(/\s+/g, '')
|
|||
|
|
.toLowerCase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _extractFirstThreadToken(value) {
|
|||
|
|
if (!value) return '';
|
|||
|
|
const first = String(value).trim().split(/[\s,]+/)[0] || '';
|
|||
|
|
return _normalizeThreadToken(first);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _normalizeThreadSubject(value) {
|
|||
|
|
if (!value) return '';
|
|||
|
|
return String(value)
|
|||
|
|
.toLowerCase()
|
|||
|
|
.replace(/^(?:(?:re|fw|fwd|sv|aw)\s*:\s*)+/i, '')
|
|||
|
|
.replace(/\s+/g, ' ')
|
|||
|
|
.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getThreadKey(email) {
|
|||
|
|
const resolved = _normalizeThreadToken(email?.resolved_thread_key);
|
|||
|
|
if (resolved && !resolved.startsWith('email-')) return resolved;
|
|||
|
|
|
|||
|
|
const direct = _normalizeThreadToken(email?.thread_key);
|
|||
|
|
if (direct && !direct.startsWith('email-')) return direct;
|
|||
|
|
|
|||
|
|
const replyTo = _extractFirstThreadToken(email?.in_reply_to);
|
|||
|
|
if (replyTo) return replyTo;
|
|||
|
|
|
|||
|
|
const refs = _extractFirstThreadToken(email?.email_references);
|
|||
|
|
if (refs) return refs;
|
|||
|
|
|
|||
|
|
const normalizedSubject = _normalizeThreadSubject(email?.subject);
|
|||
|
|
if (normalizedSubject) return `subject:${normalizedSubject}`;
|
|||
|
|
|
|||
|
|
const messageId = _normalizeThreadToken(email?.message_id);
|
|||
|
|
if (messageId) return messageId;
|
|||
|
|
|
|||
|
|
return `email-${email?.id || 'unknown'}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isOutgoingEmail(email) {
|
|||
|
|
if (typeof email?.is_outgoing === 'boolean') {
|
|||
|
|
return email.is_outgoing;
|
|||
|
|
}
|
|||
|
|
const folder = (email?.folder || '').toString().toLowerCase();
|
|||
|
|
const status = (email?.status || '').toString().toLowerCase();
|
|||
|
|
return folder.startsWith('sent') || status === 'sent';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildThreadGroups(emails) {
|
|||
|
|
const map = new Map();
|
|||
|
|
|
|||
|
|
emails.forEach((email) => {
|
|||
|
|
const threadKey = getThreadKey(email);
|
|||
|
|
const existing = map.get(threadKey);
|
|||
|
|
const receivedDateMs = email.received_date ? new Date(email.received_date).getTime() : 0;
|
|||
|
|
|
|||
|
|
if (!existing) {
|
|||
|
|
map.set(threadKey, {
|
|||
|
|
threadKey,
|
|||
|
|
lastDateMs: receivedDateMs,
|
|||
|
|
latestEmail: email,
|
|||
|
|
emails: [email]
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
existing.emails.push(email);
|
|||
|
|
if (receivedDateMs > existing.lastDateMs) {
|
|||
|
|
existing.lastDateMs = receivedDateMs;
|
|||
|
|
existing.latestEmail = email;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return Array.from(map.values())
|
|||
|
|
.map((group) => {
|
|||
|
|
group.emails.sort((a, b) => {
|
|||
|
|
const aDate = a.received_date ? new Date(a.received_date).getTime() : 0;
|
|||
|
|
const bDate = b.received_date ? new Date(b.received_date).getTime() : 0;
|
|||
|
|
return bDate - aDate;
|
|||
|
|
});
|
|||
|
|
return group;
|
|||
|
|
})
|
|||
|
|
.sort((a, b) => b.lastDateMs - a.lastDateMs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCurrentThreadEmails() {
|
|||
|
|
if (!selectedEmailThreadKey) return [];
|
|||
|
|
return filteredLinkedEmailsCache
|
|||
|
|
.filter((email) => getThreadKey(email) === selectedEmailThreadKey)
|
|||
|
|
.sort((a, b) => {
|
|||
|
|
const aDate = a.received_date ? new Date(a.received_date).getTime() : 0;
|
|||
|
|
const bDate = b.received_date ? new Date(b.received_date).getTime() : 0;
|
|||
|
|
return bDate - aDate;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderEmailThreads(threadGroups) {
|
|||
|
|
const container = document.getElementById('email-threads-list');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
if (!threadGroups.length) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen tråde fundet...</div>';
|
|||
|
|
const counter = document.getElementById('linkedEmailThreadsCount');
|
|||
|
|
if (counter) counter.textContent = '0';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const counter = document.getElementById('linkedEmailThreadsCount');
|
|||
|
|
if (counter) counter.textContent = String(threadGroups.length);
|
|||
|
|
|
|||
|
|
const getThreadParticipants = (group) => {
|
|||
|
|
const participants = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
|
|||
|
|
(group.emails || []).forEach((mail) => {
|
|||
|
|
const sender = (mail.sender_name || mail.sender_email || '').trim();
|
|||
|
|
if (sender && !seen.has(sender.toLowerCase())) {
|
|||
|
|
participants.push(sender);
|
|||
|
|
seen.add(sender.toLowerCase());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isOutgoingEmail(mail)) {
|
|||
|
|
const recipients = String(mail.recipient_email || '')
|
|||
|
|
.split(',')
|
|||
|
|
.map((item) => item.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
recipients.forEach((recipient) => {
|
|||
|
|
const key = recipient.toLowerCase();
|
|||
|
|
if (!seen.has(key)) {
|
|||
|
|
participants.push(recipient);
|
|||
|
|
seen.add(key);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!participants.length) return 'Deltagere: -';
|
|||
|
|
const visible = participants.slice(0, 3);
|
|||
|
|
const extra = participants.length - visible.length;
|
|||
|
|
return `Deltagere: ${visible.join(', ')}${extra > 0 ? ` +${extra}` : ''}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
container.innerHTML = threadGroups.map((group) => {
|
|||
|
|
const latest = group.latestEmail || {};
|
|||
|
|
const isSelected = selectedEmailThreadKey === group.threadKey;
|
|||
|
|
const receivedDate = latest.received_date ? new Date(latest.received_date).toLocaleString('da-DK') : '-';
|
|||
|
|
const sender = latest.sender_name || latest.sender_email || '-';
|
|||
|
|
const subject = latest.subject || '(Ingen emne)';
|
|||
|
|
const unreadCount = group.emails.filter((item) => !item.is_read).length;
|
|||
|
|
const participantsLabel = getThreadParticipants(group);
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<button type="button" class="email-thread-item list-group-item list-group-item-action border-0 border-bottom text-start ${isSelected ? 'active' : ''}" onclick='selectEmailThread(${JSON.stringify(group.threadKey)})'>
|
|||
|
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
|||
|
|
<div class="flex-grow-1 overflow-hidden">
|
|||
|
|
<div class="fw-semibold text-truncate">${escapeHtml(subject)}</div>
|
|||
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(sender)}</div>
|
|||
|
|
<div class="participants-line text-truncate">${escapeHtml(participantsLabel)}</div>
|
|||
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(receivedDate)}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|||
|
|
<span class="badge ${isSelected ? 'bg-light text-dark' : 'bg-secondary'}">${group.emails.length}</span>
|
|||
|
|
${unreadCount > 0 ? `<span class="badge bg-warning text-dark">${unreadCount} ulæst</span>` : ''}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectEmailThread(threadKey) {
|
|||
|
|
selectedEmailThreadKey = String(threadKey || '');
|
|||
|
|
|
|||
|
|
const threadGroups = buildThreadGroups(filteredLinkedEmailsCache);
|
|||
|
|
renderEmailThreads(threadGroups);
|
|||
|
|
|
|||
|
|
const threadEmails = getCurrentThreadEmails();
|
|||
|
|
renderLinkedEmails(threadEmails);
|
|||
|
|
|
|||
|
|
if (!threadEmails.length) {
|
|||
|
|
selectedLinkedEmailId = null;
|
|||
|
|
renderEmailPreviewEmpty();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasCurrentSelected = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId));
|
|||
|
|
if (!hasCurrentSelected) {
|
|||
|
|
selectedLinkedEmailId = Number(threadEmails[0].id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
loadLinkedEmailDetail(selectedLinkedEmailId, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function applyLinkedEmailFilters(loadDetail = false) {
|
|||
|
|
filteredLinkedEmailsCache = getFilteredLinkedEmails();
|
|||
|
|
updateEmailTabUnreadBadge();
|
|||
|
|
const threadGroups = buildThreadGroups(filteredLinkedEmailsCache);
|
|||
|
|
|
|||
|
|
renderEmailThreads(threadGroups);
|
|||
|
|
|
|||
|
|
if (!threadGroups.length) {
|
|||
|
|
selectedEmailThreadKey = null;
|
|||
|
|
selectedLinkedEmailId = null;
|
|||
|
|
renderLinkedEmails([]);
|
|||
|
|
const threadCounter = document.getElementById('threadEmailsCount');
|
|||
|
|
if (threadCounter) threadCounter.textContent = '0';
|
|||
|
|
renderEmailPreviewEmpty();
|
|||
|
|
setModuleContentState('emails', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const selectedThreadExists = threadGroups.some((group) => group.threadKey === selectedEmailThreadKey);
|
|||
|
|
if (!selectedThreadExists) {
|
|||
|
|
selectedEmailThreadKey = threadGroups[0].threadKey;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const threadEmails = getCurrentThreadEmails();
|
|||
|
|
renderLinkedEmails(threadEmails);
|
|||
|
|
|
|||
|
|
const threadCounter = document.getElementById('threadEmailsCount');
|
|||
|
|
if (threadCounter) threadCounter.textContent = String(threadEmails.length);
|
|||
|
|
|
|||
|
|
if (!threadEmails.length) {
|
|||
|
|
selectedLinkedEmailId = null;
|
|||
|
|
renderEmailPreviewEmpty();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const selectedEmailExists = threadEmails.some((item) => Number(item.id) === Number(selectedLinkedEmailId));
|
|||
|
|
if (!selectedEmailExists) {
|
|||
|
|
selectedLinkedEmailId = Number(threadEmails[0].id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (loadDetail && selectedLinkedEmailId) {
|
|||
|
|
await loadLinkedEmailDetail(selectedLinkedEmailId, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setModuleContentState('emails', true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderLinkedEmails(emails) {
|
|||
|
|
const container = document.getElementById('linked-emails-list');
|
|||
|
|
if (!container) return;
|
|||
|
|
if(!emails || emails.length === 0) {
|
|||
|
|
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen linkede e-mails...</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = emails.map(e => {
|
|||
|
|
const isSelected = Number(selectedLinkedEmailId) === Number(e.id);
|
|||
|
|
const receivedDate = e.received_date ? new Date(e.received_date).toLocaleString('da-DK') : '-';
|
|||
|
|
const sender = e.sender_name || e.sender_email || '-';
|
|||
|
|
const subject = e.subject || '(Ingen emne)';
|
|||
|
|
const isOutgoing = isOutgoingEmail(e);
|
|||
|
|
const snippetSource = e.body_text || e.body_html || '';
|
|||
|
|
const snippet = snippetSource.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 130);
|
|||
|
|
const hasAttachments = Boolean(e.has_attachments) || Number(e.attachment_count || 0) > 0;
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<button type="button" class="list-group-item list-group-item-action border-0 border-bottom text-start ${isSelected ? 'active' : ''}" onclick="loadLinkedEmailDetail(${e.id})">
|
|||
|
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
|||
|
|
<div class="flex-grow-1 overflow-hidden">
|
|||
|
|
<div class="fw-semibold text-truncate">${escapeHtml(subject)}</div>
|
|||
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(sender)}</div>
|
|||
|
|
<div class="small mt-1">${isOutgoing
|
|||
|
|
? '<span class="badge bg-primary-subtle text-primary-emphasis">Udgående</span>'
|
|||
|
|
: '<span class="badge bg-success-subtle text-success-emphasis">Indgående</span>'
|
|||
|
|
}</div>
|
|||
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'} text-truncate">${escapeHtml(snippet || 'Ingen preview')}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|||
|
|
<div class="small ${isSelected ? 'text-white-50' : 'text-muted'}">${escapeHtml(receivedDate)}</div>
|
|||
|
|
${hasAttachments ? '<span class="badge bg-info-subtle text-info-emphasis">📎</span>' : ''}
|
|||
|
|
${!e.is_read ? '<span class="badge bg-warning text-dark">Ulæst</span>' : ''}
|
|||
|
|
<span class="btn btn-sm btn-link p-0 ${isSelected ? 'text-white' : 'text-danger'}" onclick="event.stopPropagation(); unlinkEmail(${e.id});" title="Fjern link">
|
|||
|
|
<i class="bi bi-link-45deg" style="text-decoration: line-through;"></i>
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderEmailPreviewEmpty() {
|
|||
|
|
const panel = document.getElementById('email-preview-panel');
|
|||
|
|
if (!panel) return;
|
|||
|
|
selectedLinkedEmailDetail = null;
|
|||
|
|
panel.innerHTML = `
|
|||
|
|
<div class="p-3 text-center text-muted d-flex align-items-center justify-content-center flex-grow-1">
|
|||
|
|
Vælg en e-mail i listen for at se indhold og vedhæftninger
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadLinkedEmailDetail(emailId, skipRefresh = false) {
|
|||
|
|
selectedLinkedEmailId = Number(emailId);
|
|||
|
|
const panel = document.getElementById('email-preview-panel');
|
|||
|
|
if (!panel) return;
|
|||
|
|
|
|||
|
|
panel.innerHTML = `
|
|||
|
|
<div class="p-4 text-center text-muted">
|
|||
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|||
|
|
Henter e-mail...
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
if (!skipRefresh) {
|
|||
|
|
const threadEmails = getCurrentThreadEmails();
|
|||
|
|
renderLinkedEmails(threadEmails);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/emails/${emailId}`);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
panel.innerHTML = '<div class="p-3 text-danger">Kunne ikke hente e-mail detaljer.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const email = await res.json();
|
|||
|
|
const subject = email.subject || '(Ingen emne)';
|
|||
|
|
const sender = email.sender_name || email.sender_email || '-';
|
|||
|
|
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
|
|||
|
|
const attachments = Array.isArray(email.attachments) ? email.attachments : [];
|
|||
|
|
const bodyText = email.body_text || '';
|
|||
|
|
const bodyHtml = email.body_html || '';
|
|||
|
|
selectedLinkedEmailDetail = email;
|
|||
|
|
|
|||
|
|
panel.innerHTML = `
|
|||
|
|
<div class="border-bottom p-3">
|
|||
|
|
<div class="fw-bold mb-1">${escapeHtml(subject)}</div>
|
|||
|
|
<div class="small text-muted">Fra: ${escapeHtml(sender)}</div>
|
|||
|
|
<div class="small text-muted">Dato: ${escapeHtml(received)}</div>
|
|||
|
|
<div class="mt-2 d-flex gap-2">
|
|||
|
|
<button type="button" class="btn btn-sm btn-primary" onclick="openReplyToLinkedEmail()">
|
|||
|
|
<i class="bi bi-reply me-1"></i>Svar i tråd
|
|||
|
|
</button>
|
|||
|
|
<button type="button" class="btn btn-sm ${email.is_read ? 'btn-outline-secondary' : 'btn-warning'}" id="emailReadToggleBtn" onclick="toggleLinkedEmailReadState()">
|
|||
|
|
<i class="bi ${email.is_read ? 'bi-envelope-open' : 'bi-envelope'} me-1"></i>
|
|||
|
|
${email.is_read ? 'Marker som ulæst' : 'Marker som læst'}
|
|||
|
|
</button>
|
|||
|
|
<span class="badge ${email.is_read ? 'bg-success-subtle text-success-emphasis' : 'bg-warning text-dark'} mail-read-chip align-self-center" id="emailReadStateBadge">
|
|||
|
|
${email.is_read ? 'Læst' : 'Ulæst'}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="p-3 border-bottom">
|
|||
|
|
<div class="small fw-semibold mb-2">Vedhæftninger (${attachments.length})</div>
|
|||
|
|
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
|
|||
|
|
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml ? bodyHtml : '<div class="text-muted">Ingen indhold</div>')}
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const attachmentContainer = document.getElementById('email-attachments-list');
|
|||
|
|
if (attachmentContainer) {
|
|||
|
|
if (!attachments.length) {
|
|||
|
|
attachmentContainer.innerHTML = '<span class="text-muted small">Ingen vedhæftninger</span>';
|
|||
|
|
} else {
|
|||
|
|
attachmentContainer.innerHTML = attachments.map(att => {
|
|||
|
|
const attachmentName = att.filename || `Vedhæftning ${att.id}`;
|
|||
|
|
const url = `/api/v1/emails/${email.id}/attachments/${att.id}`;
|
|||
|
|
return `<a class="btn btn-sm btn-outline-secondary" href="${url}"><i class="bi bi-download me-1"></i>${escapeHtml(attachmentName)}</a>`;
|
|||
|
|
}).join('');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const cacheIdx = linkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id));
|
|||
|
|
if (cacheIdx >= 0) {
|
|||
|
|
linkedEmailsCache[cacheIdx].is_read = Boolean(email.is_read);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const filteredIdx = filteredLinkedEmailsCache.findIndex((item) => Number(item.id) === Number(email.id));
|
|||
|
|
if (filteredIdx >= 0) {
|
|||
|
|
filteredLinkedEmailsCache[filteredIdx].is_read = Boolean(email.is_read);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateEmailTabUnreadBadge();
|
|||
|
|
|
|||
|
|
if (!skipRefresh) {
|
|||
|
|
const threadEmails = getCurrentThreadEmails();
|
|||
|
|
renderLinkedEmails(threadEmails);
|
|||
|
|
renderEmailThreads(buildThreadGroups(filteredLinkedEmailsCache));
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
selectedLinkedEmailDetail = null;
|
|||
|
|
panel.innerHTML = '<div class="p-3 text-danger">Fejl ved hentning af e-mail detaljer.</div>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleLinkedEmailReadState() {
|
|||
|
|
if (!selectedLinkedEmailDetail || !selectedLinkedEmailId) return;
|
|||
|
|
const targetState = !Boolean(selectedLinkedEmailDetail.is_read);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/emails/${selectedLinkedEmailId}/read-state`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ is_read: targetState })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let detail = `Kunne ikke opdatere læsestatus (${res.status})`;
|
|||
|
|
try {
|
|||
|
|
const payload = await res.json();
|
|||
|
|
if (payload?.detail) detail = payload.detail;
|
|||
|
|
} catch (_) {
|
|||
|
|
}
|
|||
|
|
alert(detail);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
selectedLinkedEmailDetail.is_read = targetState;
|
|||
|
|
|
|||
|
|
const updateCollection = (items) => {
|
|||
|
|
const idx = items.findIndex((entry) => Number(entry.id) === Number(selectedLinkedEmailId));
|
|||
|
|
if (idx >= 0) {
|
|||
|
|
items[idx].is_read = targetState;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
updateCollection(linkedEmailsCache);
|
|||
|
|
updateCollection(filteredLinkedEmailsCache);
|
|||
|
|
updateEmailTabUnreadBadge();
|
|||
|
|
|
|||
|
|
const threadEmails = getCurrentThreadEmails();
|
|||
|
|
renderLinkedEmails(threadEmails);
|
|||
|
|
renderEmailThreads(buildThreadGroups(filteredLinkedEmailsCache));
|
|||
|
|
|
|||
|
|
const toggleBtn = document.getElementById('emailReadToggleBtn');
|
|||
|
|
if (toggleBtn) {
|
|||
|
|
toggleBtn.className = `btn btn-sm ${targetState ? 'btn-outline-secondary' : 'btn-warning'}`;
|
|||
|
|
toggleBtn.innerHTML = `<i class="bi ${targetState ? 'bi-envelope-open' : 'bi-envelope'} me-1"></i>${targetState ? 'Marker som ulæst' : 'Marker som læst'}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const badge = document.getElementById('emailReadStateBadge');
|
|||
|
|
if (badge) {
|
|||
|
|
badge.className = `badge ${targetState ? 'bg-success-subtle text-success-emphasis' : 'bg-warning text-dark'} mail-read-chip align-self-center`;
|
|||
|
|
badge.textContent = targetState ? 'Læst' : 'Ulæst';
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(error);
|
|||
|
|
alert('Fejl ved opdatering af læsestatus.');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function unlinkEmail(emailId) {
|
|||
|
|
if(!confirm("Fjern link til denne email?")) return;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/email-links/${emailId}`, { method: 'DELETE' });
|
|||
|
|
if(res.ok) {
|
|||
|
|
if (Number(selectedLinkedEmailId) === Number(emailId)) {
|
|||
|
|
selectedLinkedEmailId = null;
|
|||
|
|
renderEmailPreviewEmpty();
|
|||
|
|
}
|
|||
|
|
loadLinkedEmails();
|
|||
|
|
}
|
|||
|
|
} catch(e) { alert(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Email Search
|
|||
|
|
const emailSearchInput = document.getElementById('emailSearchInput');
|
|||
|
|
const emailSearchResults = document.getElementById('emailSearchResults');
|
|||
|
|
let emailDebounce = null;
|
|||
|
|
|
|||
|
|
if(emailSearchInput) {
|
|||
|
|
emailSearchInput.addEventListener('input', e => {
|
|||
|
|
clearTimeout(emailDebounce);
|
|||
|
|
const q = e.target.value.trim();
|
|||
|
|
if(q.length < 2) {
|
|||
|
|
emailSearchResults.style.display = 'none';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
emailDebounce = setTimeout(() => searchEmails(q), 300);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Hide on outside click
|
|||
|
|
document.addEventListener('click', e => {
|
|||
|
|
if(!emailSearchInput.contains(e.target) && !emailSearchResults.contains(e.target)) {
|
|||
|
|
emailSearchResults.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
['emailFilterInput', 'emailAttachmentFilter', 'emailReadFilter'].forEach((id) => {
|
|||
|
|
const el = document.getElementById(id);
|
|||
|
|
if (!el) return;
|
|||
|
|
const eventName = id === 'emailFilterInput' ? 'input' : 'change';
|
|||
|
|
el.addEventListener(eventName, () => {
|
|||
|
|
applyLinkedEmailFilters(true);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function searchEmails(query) {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
|
|||
|
|
if(res.ok) {
|
|||
|
|
const emails = await res.json();
|
|||
|
|
renderEmailSuggestions(emails);
|
|||
|
|
emailSearchResults.style.display = 'block';
|
|||
|
|
}
|
|||
|
|
} catch(e) { console.error(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderEmailSuggestions(emails) {
|
|||
|
|
if(!emails.length) {
|
|||
|
|
emailSearchResults.innerHTML = '<div class="list-group-item text-muted">Ingen fundet</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
emailSearchResults.innerHTML = emails.map(e => `
|
|||
|
|
<button class="list-group-item list-group-item-action" onclick="linkEmail(${e.id})">
|
|||
|
|
<div class="fw-bold text-truncate">${e.subject}</div>
|
|||
|
|
<div class="small text-muted">${e.sender_email}</div>
|
|||
|
|
</button>
|
|||
|
|
`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function linkEmail(emailId) {
|
|||
|
|
emailSearchInput.value = '';
|
|||
|
|
emailSearchResults.style.display = 'none';
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({email_id: emailId})
|
|||
|
|
});
|
|||
|
|
if(res.ok) loadLinkedEmails();
|
|||
|
|
else alert("Kunne ikke linke email");
|
|||
|
|
} catch(e) { alert(e); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Email Import Drag & Drop (.msg / .eml)
|
|||
|
|
const emailDropZone = document.getElementById('emailDropZone');
|
|||
|
|
if(emailDropZone) {
|
|||
|
|
emailDropZone.addEventListener('dragover', e => { e.preventDefault(); emailDropZone.classList.add('bg-warning-subtle'); });
|
|||
|
|
emailDropZone.addEventListener('dragleave', e => { e.preventDefault(); emailDropZone.classList.remove('bg-warning-subtle'); });
|
|||
|
|
emailDropZone.addEventListener('drop', e => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
emailDropZone.classList.remove('bg-warning-subtle');
|
|||
|
|
const files = e.dataTransfer.files;
|
|||
|
|
if(files.length) uploadEmailFile(files[0]);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function uploadEmailFile(file) {
|
|||
|
|
if (!file) return;
|
|||
|
|
const lowerName = String(file.name || '').toLowerCase();
|
|||
|
|
if (!(lowerName.endsWith('.eml') || lowerName.endsWith('.msg'))) {
|
|||
|
|
alert('Kun .eml og .msg filer understøttes');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('file', file);
|
|||
|
|
|
|||
|
|
// Show busy indicator
|
|||
|
|
emailDropZone.style.opacity = '0.5';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}/upload-email`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: formData
|
|||
|
|
});
|
|||
|
|
if(res.ok) {
|
|||
|
|
loadLinkedEmails();
|
|||
|
|
} else {
|
|||
|
|
alert('Import fejlede');
|
|||
|
|
}
|
|||
|
|
} catch(e) { alert(e); }
|
|||
|
|
finally {
|
|||
|
|
emailDropZone.style.opacity = '1';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Load content on start
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
const caseEmailSendBtn = document.getElementById('caseEmailSendBtn');
|
|||
|
|
if (caseEmailSendBtn) {
|
|||
|
|
caseEmailSendBtn.addEventListener('click', sendCaseEmail);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const caseEmailRewriteBtn = document.getElementById('caseEmailRewriteBtn');
|
|||
|
|
if (caseEmailRewriteBtn) {
|
|||
|
|
caseEmailRewriteBtn.addEventListener('click', rewriteCaseEmailWithApproval);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rewriteApplyAllBtn = document.getElementById('rewriteApplyAllBtn');
|
|||
|
|
if (rewriteApplyAllBtn) {
|
|||
|
|
rewriteApplyAllBtn.addEventListener('click', () => applyRewriteChanges('all'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rewriteApplySelectedBtn = document.getElementById('rewriteApplySelectedBtn');
|
|||
|
|
if (rewriteApplySelectedBtn) {
|
|||
|
|
rewriteApplySelectedBtn.addEventListener('click', () => applyRewriteChanges('selected'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const caseEmailComposeModal = document.getElementById('caseEmailComposeModal');
|
|||
|
|
if (caseEmailComposeModal) {
|
|||
|
|
caseEmailComposeModal.addEventListener('show.bs.modal', () => {
|
|||
|
|
const statusEl = document.getElementById('caseEmailSendStatus');
|
|||
|
|
if (statusEl) {
|
|||
|
|
statusEl.className = 'text-muted';
|
|||
|
|
statusEl.textContent = '';
|
|||
|
|
}
|
|||
|
|
prefillCaseEmailCompose();
|
|||
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
prefillCaseEmailCompose();
|
|||
|
|
updateCaseEmailAttachmentOptions(sagFilesCache);
|
|||
|
|
loadSagFiles();
|
|||
|
|
loadLinkedEmails();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const subscriptionCaseId = {{ case.id }};
|
|||
|
|
let currentSubscription = null;
|
|||
|
|
let subscriptionProducts = [];
|
|||
|
|
let lastCreatedSubscriptionProductId = null;
|
|||
|
|
|
|||
|
|
function formatSubscriptionInterval(interval) {
|
|||
|
|
const map = {
|
|||
|
|
'daily': 'Daglig',
|
|||
|
|
'biweekly': '14-dage',
|
|||
|
|
'monthly': 'Maaned',
|
|||
|
|
'quarterly': 'Kvartal',
|
|||
|
|
'yearly': 'Aar'
|
|||
|
|
};
|
|||
|
|
return map[interval] || interval || '-';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatSubscriptionCurrency(amount) {
|
|||
|
|
return new Intl.NumberFormat('da-DK', {
|
|||
|
|
style: 'currency',
|
|||
|
|
currency: 'DKK',
|
|||
|
|
minimumFractionDigits: 0,
|
|||
|
|
maximumFractionDigits: 0
|
|||
|
|
}).format(amount || 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatSubscriptionDate(dateStr) {
|
|||
|
|
if (!dateStr) return '-';
|
|||
|
|
const date = new Date(dateStr);
|
|||
|
|
return date.toLocaleDateString('da-DK');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setSubscriptionBadge(status) {
|
|||
|
|
const badge = document.getElementById('subscriptionStatusBadge');
|
|||
|
|
if (!badge) return;
|
|||
|
|
const classes = {
|
|||
|
|
'draft': 'bg-light text-dark',
|
|||
|
|
'active': 'bg-success',
|
|||
|
|
'paused': 'bg-warning',
|
|||
|
|
'cancelled': 'bg-secondary'
|
|||
|
|
};
|
|||
|
|
const label = {
|
|||
|
|
'draft': 'Kladde',
|
|||
|
|
'active': 'Aktiv',
|
|||
|
|
'paused': 'Pauset',
|
|||
|
|
'cancelled': 'Opsagt'
|
|||
|
|
};
|
|||
|
|
badge.className = `badge ${classes[status] || 'bg-light text-dark'}`;
|
|||
|
|
badge.textContent = label[status] || status || 'Ingen';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showSubscriptionCreateForm() {
|
|||
|
|
const empty = document.getElementById('subscriptionEmpty');
|
|||
|
|
const form = document.getElementById('subscriptionCreateForm');
|
|||
|
|
const details = document.getElementById('subscriptionDetails');
|
|||
|
|
if (empty) empty.classList.remove('d-none');
|
|||
|
|
if (form) form.classList.remove('d-none');
|
|||
|
|
if (details) details.classList.add('d-none');
|
|||
|
|
setSubscriptionBadge(null);
|
|||
|
|
|
|||
|
|
const startDateInput = document.getElementById('subscriptionStartDateInput');
|
|||
|
|
if (startDateInput && !startDateInput.value) {
|
|||
|
|
startDateInput.value = new Date().toISOString().split('T')[0];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|||
|
|
if (body) {
|
|||
|
|
body.innerHTML = `
|
|||
|
|
<tr>
|
|||
|
|
<td>
|
|||
|
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
|||
|
|
<option value="">Vælg produkt</option>
|
|||
|
|
</select>
|
|||
|
|
</td>
|
|||
|
|
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
|
|||
|
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
|||
|
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
|||
|
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
|||
|
|
<td class="text-end">
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
populateSubscriptionProductSelects();
|
|||
|
|
updateSubscriptionLineTotals();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function populateSubscriptionProductSelects() {
|
|||
|
|
const selects = document.querySelectorAll('.subscriptionProductSelect');
|
|||
|
|
selects.forEach(select => {
|
|||
|
|
const currentValue = select.value;
|
|||
|
|
select.innerHTML = '<option value="">Vælg produkt</option>';
|
|||
|
|
subscriptionProducts.forEach(product => {
|
|||
|
|
const option = document.createElement('option');
|
|||
|
|
option.value = product.id;
|
|||
|
|
option.textContent = product.name;
|
|||
|
|
option.dataset.salesPrice = product.sales_price ?? '';
|
|||
|
|
option.dataset.description = product.short_description ?? '';
|
|||
|
|
select.appendChild(option);
|
|||
|
|
});
|
|||
|
|
if (currentValue) {
|
|||
|
|
select.value = currentValue;
|
|||
|
|
} else if (lastCreatedSubscriptionProductId) {
|
|||
|
|
select.value = String(lastCreatedSubscriptionProductId);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
lastCreatedSubscriptionProductId = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applySubscriptionProduct(select) {
|
|||
|
|
const row = select.closest('tr');
|
|||
|
|
if (!row) return;
|
|||
|
|
const descriptionInput = row.querySelector('input[type="text"]');
|
|||
|
|
const unitPriceInput = row.querySelectorAll('input[type="number"]')[1];
|
|||
|
|
const selected = select.options[select.selectedIndex];
|
|||
|
|
if (!selected) return;
|
|||
|
|
|
|||
|
|
const description = selected.dataset.description || selected.textContent || '';
|
|||
|
|
const salesPrice = selected.dataset.salesPrice;
|
|||
|
|
|
|||
|
|
if (descriptionInput && !descriptionInput.value.trim()) {
|
|||
|
|
descriptionInput.value = description;
|
|||
|
|
}
|
|||
|
|
if (unitPriceInput && salesPrice !== '') {
|
|||
|
|
unitPriceInput.value = salesPrice;
|
|||
|
|
}
|
|||
|
|
updateSubscriptionLineTotals();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function addSubscriptionLine() {
|
|||
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|||
|
|
if (!body) return;
|
|||
|
|
const row = document.createElement('tr');
|
|||
|
|
row.innerHTML = `
|
|||
|
|
<td>
|
|||
|
|
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
|
|||
|
|
<option value="">Vælg produkt</option>
|
|||
|
|
</select>
|
|||
|
|
</td>
|
|||
|
|
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
|
|||
|
|
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
|
|||
|
|
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
|
|||
|
|
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
|
|||
|
|
<td class="text-end">
|
|||
|
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
|
|||
|
|
</td>
|
|||
|
|
`;
|
|||
|
|
body.appendChild(row);
|
|||
|
|
populateSubscriptionProductSelects();
|
|||
|
|
updateSubscriptionLineTotals();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeSubscriptionLine(button) {
|
|||
|
|
const row = button.closest('tr');
|
|||
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|||
|
|
if (!row || !body) return;
|
|||
|
|
if (body.children.length <= 1) {
|
|||
|
|
row.querySelectorAll('input').forEach(input => {
|
|||
|
|
input.value = input.type === 'number' ? 0 : '';
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
row.remove();
|
|||
|
|
}
|
|||
|
|
updateSubscriptionLineTotals();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateSubscriptionLineTotals() {
|
|||
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|||
|
|
const totalEl = document.getElementById('subscriptionLinesTotal');
|
|||
|
|
if (!body || !totalEl) return;
|
|||
|
|
|
|||
|
|
let total = 0;
|
|||
|
|
Array.from(body.querySelectorAll('tr')).forEach(row => {
|
|||
|
|
const inputs = row.querySelectorAll('input');
|
|||
|
|
const description = inputs[0]?.value || '';
|
|||
|
|
const qty = parseFloat(inputs[1]?.value || 0);
|
|||
|
|
const unit = parseFloat(inputs[2]?.value || 0);
|
|||
|
|
const lineTotal = (qty > 0 ? qty : 0) * (unit > 0 ? unit : 0);
|
|||
|
|
total += lineTotal;
|
|||
|
|
const lineTotalEl = row.querySelector('.subscriptionLineTotal');
|
|||
|
|
if (lineTotalEl) {
|
|||
|
|
lineTotalEl.textContent = formatSubscriptionCurrency(lineTotal);
|
|||
|
|
}
|
|||
|
|
if (!description && qty === 0 && unit === 0) {
|
|||
|
|
if (lineTotalEl) {
|
|||
|
|
lineTotalEl.textContent = formatSubscriptionCurrency(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
totalEl.textContent = formatSubscriptionCurrency(total);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function collectSubscriptionLineItems() {
|
|||
|
|
const body = document.getElementById('subscriptionLineItemsBody');
|
|||
|
|
if (!body) return [];
|
|||
|
|
const items = [];
|
|||
|
|
Array.from(body.querySelectorAll('tr')).forEach(row => {
|
|||
|
|
const productSelect = row.querySelector('.subscriptionProductSelect');
|
|||
|
|
const inputs = row.querySelectorAll('input');
|
|||
|
|
const description = (inputs[0]?.value || '').trim();
|
|||
|
|
const quantity = parseFloat(inputs[1]?.value || 0);
|
|||
|
|
const unitPrice = parseFloat(inputs[2]?.value || 0);
|
|||
|
|
if (!description && quantity === 0 && unitPrice === 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
items.push({
|
|||
|
|
product_id: productSelect && productSelect.value ? parseInt(productSelect.value, 10) : null,
|
|||
|
|
description,
|
|||
|
|
quantity,
|
|||
|
|
unit_price: unitPrice
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
return items;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadSubscriptionProducts() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/products');
|
|||
|
|
if (!res.ok) {
|
|||
|
|
throw new Error('Kunne ikke hente produkter');
|
|||
|
|
}
|
|||
|
|
subscriptionProducts = await res.json();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Error loading products:', e);
|
|||
|
|
subscriptionProducts = [];
|
|||
|
|
}
|
|||
|
|
populateSubscriptionProductSelects();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openSubscriptionProductModal() {
|
|||
|
|
const form = document.getElementById('subscriptionProductForm');
|
|||
|
|
if (form) form.reset();
|
|||
|
|
new bootstrap.Modal(document.getElementById('subscriptionProductModal')).show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createSubscriptionProduct() {
|
|||
|
|
const payload = {
|
|||
|
|
name: document.getElementById('subscriptionProductName').value.trim(),
|
|||
|
|
type: document.getElementById('subscriptionProductType').value.trim() || null,
|
|||
|
|
status: document.getElementById('subscriptionProductStatus').value,
|
|||
|
|
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
|
|||
|
|
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
|
|||
|
|
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (!payload.name) {
|
|||
|
|
alert('Navn er paakraevet');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const res = await fetch('/api/v1/products', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const error = await res.json();
|
|||
|
|
alert(error.detail || 'Kunne ikke oprette produkt');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const product = await res.json();
|
|||
|
|
lastCreatedSubscriptionProductId = product.id;
|
|||
|
|
bootstrap.Modal.getInstance(document.getElementById('subscriptionProductModal')).hide();
|
|||
|
|
await loadSubscriptionProducts();
|
|||
|
|
updateSubscriptionLineTotals();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSubscription(subscription) {
|
|||
|
|
currentSubscription = subscription;
|
|||
|
|
const empty = document.getElementById('subscriptionEmpty');
|
|||
|
|
const form = document.getElementById('subscriptionCreateForm');
|
|||
|
|
const details = document.getElementById('subscriptionDetails');
|
|||
|
|
if (empty) empty.classList.add('d-none');
|
|||
|
|
if (form) form.classList.add('d-none');
|
|||
|
|
if (details) details.classList.remove('d-none');
|
|||
|
|
|
|||
|
|
document.getElementById('subscriptionNumber').textContent = subscription.subscription_number || `#${subscription.id}`;
|
|||
|
|
document.getElementById('subscriptionProduct').textContent = subscription.product_name || '-';
|
|||
|
|
document.getElementById('subscriptionInterval').textContent = formatSubscriptionInterval(subscription.billing_interval);
|
|||
|
|
document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price);
|
|||
|
|
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
|
|||
|
|
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
|
|||
|
|
|
|||
|
|
// New fields
|
|||
|
|
const periodStartEl = document.getElementById('subscriptionPeriodStart');
|
|||
|
|
const nextInvoiceEl = document.getElementById('subscriptionNextInvoice');
|
|||
|
|
if (periodStartEl) {
|
|||
|
|
periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-';
|
|||
|
|
}
|
|||
|
|
if (nextInvoiceEl) {
|
|||
|
|
const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-';
|
|||
|
|
nextInvoiceEl.textContent = nextDate;
|
|||
|
|
// Highlight if invoice is due soon
|
|||
|
|
if (subscription.next_invoice_date) {
|
|||
|
|
const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24));
|
|||
|
|
if (daysUntil <= 7 && daysUntil >= 0) {
|
|||
|
|
nextInvoiceEl.innerHTML = `${nextDate} <span class="badge bg-warning text-dark">Om ${daysUntil} dage</span>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setSubscriptionBadge(subscription.status);
|
|||
|
|
|
|||
|
|
const itemsBody = document.getElementById('subscriptionItemsBody');
|
|||
|
|
const itemsTotal = document.getElementById('subscriptionItemsTotal');
|
|||
|
|
if (itemsBody) {
|
|||
|
|
const items = subscription.line_items || [];
|
|||
|
|
if (!items.length) {
|
|||
|
|
itemsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen linjer</td></tr>';
|
|||
|
|
} else {
|
|||
|
|
itemsBody.innerHTML = items.map(item => `
|
|||
|
|
<tr>
|
|||
|
|
<td>${item.product_name || '-'}</td>
|
|||
|
|
<td>${item.description}</td>
|
|||
|
|
<td class="text-end">${parseFloat(item.quantity).toFixed(2)}</td>
|
|||
|
|
<td class="text-end">${formatSubscriptionCurrency(item.unit_price)}</td>
|
|||
|
|
<td class="text-end">${formatSubscriptionCurrency(item.line_total)}</td>
|
|||
|
|
</tr>
|
|||
|
|
`).join('');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (itemsTotal) {
|
|||
|
|
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const actions = document.getElementById('subscriptionActions');
|
|||
|
|
if (!actions) return;
|
|||
|
|
|
|||
|
|
const buttons = [];
|
|||
|
|
if (subscription.status === 'draft' || subscription.status === 'paused') {
|
|||
|
|
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
|
|||
|
|
}
|
|||
|
|
if (subscription.status === 'active') {
|
|||
|
|
buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`);
|
|||
|
|
}
|
|||
|
|
if (subscription.status !== 'cancelled') {
|
|||
|
|
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
|
|||
|
|
}
|
|||
|
|
actions.innerHTML = buttons.join(' ');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadSubscriptionForCase() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}?allow_missing=true`);
|
|||
|
|
if (res.status === 404) {
|
|||
|
|
showSubscriptionCreateForm();
|
|||
|
|
setModuleContentState('subscription', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!res.ok) {
|
|||
|
|
throw new Error('Kunne ikke hente abonnement');
|
|||
|
|
}
|
|||
|
|
const payload = await res.json();
|
|||
|
|
const subscription = payload && Object.prototype.hasOwnProperty.call(payload, 'subscription')
|
|||
|
|
? payload.subscription
|
|||
|
|
: payload;
|
|||
|
|
if (!subscription || !subscription.id) {
|
|||
|
|
showSubscriptionCreateForm();
|
|||
|
|
setModuleContentState('subscription', false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
renderSubscription(subscription);
|
|||
|
|
setModuleContentState('subscription', true);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Error loading subscription:', e);
|
|||
|
|
showSubscriptionCreateForm();
|
|||
|
|
setModuleContentState('subscription', true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createSubscription() {
|
|||
|
|
const billingInterval = document.getElementById('subscriptionIntervalInput').value;
|
|||
|
|
const billingDay = parseInt(document.getElementById('subscriptionBillingDayInput').value, 10);
|
|||
|
|
const startDate = document.getElementById('subscriptionStartDateInput').value;
|
|||
|
|
const notes = document.getElementById('subscriptionNotesInput').value.trim();
|
|||
|
|
|
|||
|
|
const lineItems = collectSubscriptionLineItems();
|
|||
|
|
|
|||
|
|
if (!billingInterval || !billingDay || !startDate) {
|
|||
|
|
alert('Udfyld venligst alle paakraevet felter');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!lineItems.length) {
|
|||
|
|
alert('Du skal angive mindst en varelinje');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/v1/sag-subscriptions', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
sag_id: subscriptionCaseId,
|
|||
|
|
billing_interval: billingInterval,
|
|||
|
|
billing_day: billingDay,
|
|||
|
|
start_date: startDate,
|
|||
|
|
notes: notes || null,
|
|||
|
|
line_items: lineItems
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const error = await res.json();
|
|||
|
|
throw new Error(error.detail || 'Fejl ved oprettelse');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const subscription = await res.json();
|
|||
|
|
renderSubscription(subscription);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.message || e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function updateSubscriptionStatus(status) {
|
|||
|
|
if (!currentSubscription) return;
|
|||
|
|
if (status === 'cancelled' && !confirm('Er du sikker paa, at abonnementet skal opsiges?')) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ status })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const error = await res.json();
|
|||
|
|
throw new Error(error.detail || 'Kunne ikke opdatere status');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updated = await res.json();
|
|||
|
|
renderSubscription(updated);
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(e.message || e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
loadSubscriptionProducts();
|
|||
|
|
loadSubscriptionForCase();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// === Quick Time Entry Functions (for inline time tracking) ===
|
|||
|
|
function toggleQuickTimeForm() {
|
|||
|
|
const container = document.getElementById('quickTimeFormContainer');
|
|||
|
|
if (container) {
|
|||
|
|
container.classList.remove('d-none');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Make function globally available for onclick handler
|
|||
|
|
window.toggleQuickTimeForm = toggleQuickTimeForm;
|
|||
|
|
|
|||
|
|
async function quickAddTime(event) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
|
|||
|
|
const form = document.getElementById('quickAddTimeForm');
|
|||
|
|
const formData = new FormData(form);
|
|||
|
|
|
|||
|
|
// Parse hours and minutes
|
|||
|
|
const hours = parseInt(formData.get('hours')) || 0;
|
|||
|
|
const minutes = parseInt(formData.get('minutes')) || 0;
|
|||
|
|
const totalHours = hours + (minutes / 60);
|
|||
|
|
|
|||
|
|
if (totalHours === 0) {
|
|||
|
|
alert('Angiv venligst timer eller minutter');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const billingSelect = document.getElementById('quickTimeBillingMethod');
|
|||
|
|
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
|
|||
|
|
let prepaidCardId = null;
|
|||
|
|
let fixedPriceAgreementId = null;
|
|||
|
|
|
|||
|
|
if (billingMethod.startsWith('card_')) {
|
|||
|
|
prepaidCardId = parseInt(billingMethod.split('_')[1]);
|
|||
|
|
billingMethod = 'prepaid';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (billingMethod.startsWith('fpa_')) {
|
|||
|
|
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
|
|||
|
|
billingMethod = 'fixed_price';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isInternal = billingMethod === 'internal';
|
|||
|
|
|
|||
|
|
// Build payload
|
|||
|
|
const payload = {
|
|||
|
|
sag_id: {{ case.id }},
|
|||
|
|
worked_date: formData.get('date'),
|
|||
|
|
original_hours: totalHours,
|
|||
|
|
description: formData.get('description'),
|
|||
|
|
billing_method: billingMethod,
|
|||
|
|
is_internal: isInternal
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (prepaidCardId) {
|
|||
|
|
payload.prepaid_card_id = prepaidCardId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (fixedPriceAgreementId) {
|
|||
|
|
payload.fixed_price_agreement_id = fixedPriceAgreementId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/timetracking/entries/internal', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const error = await response.json();
|
|||
|
|
throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Success - reload page to show new entry
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Fejl: ' + error.message);
|
|||
|
|
console.error('Quick add time error:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Set today's date as default for quick time form
|
|||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|||
|
|
const dateInput = document.getElementById('quickTimeDate');
|
|||
|
|
if (dateInput && !dateInput.value) {
|
|||
|
|
const today = new Date().toISOString().split('T')[0];
|
|||
|
|
dateInput.value = today;
|
|||
|
|
}
|
|||
|
|
// Activate tab from ?tab= URL parameter (used when navigating from relation tree QA menu)
|
|||
|
|
const tabParam = new URLSearchParams(window.location.search).get('tab');
|
|||
|
|
if (tabParam) {
|
|||
|
|
const tabBtn = document.getElementById(tabParam + '-tab')
|
|||
|
|
|| document.querySelector(`[data-module-tab="${tabParam}"]`);
|
|||
|
|
if (tabBtn) {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
bootstrap.Tab.getOrCreateInstance(tabBtn).show();
|
|||
|
|
forceCaseTabActivation(tabParam);
|
|||
|
|
}, 300);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// Fallback bindings: keep relation modal buttons working even if an earlier script fails.
|
|||
|
|
(function () {
|
|||
|
|
function ensureAndShowModal(modalId) {
|
|||
|
|
const modalEl = document.getElementById(modalId);
|
|||
|
|
if (!modalEl || !window.bootstrap || !window.bootstrap.Modal) return false;
|
|||
|
|
const instance = window.bootstrap.Modal.getOrCreateInstance(modalEl);
|
|||
|
|
instance.show();
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.showRelationModal = function () {
|
|||
|
|
const opened = ensureAndShowModal('relationModal');
|
|||
|
|
if (opened) {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const input = document.getElementById('relationCaseSearch');
|
|||
|
|
if (input) input.focus();
|
|||
|
|
}, 250);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.showCreateRelatedModal = function () {
|
|||
|
|
const opened = ensureAndShowModal('createRelatedCaseModal');
|
|||
|
|
if (opened && typeof window.updateNewCaseRelationTypeHint === 'function') {
|
|||
|
|
window.updateNewCaseRelationTypeHint();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (typeof window.createRelatedCase !== 'function') {
|
|||
|
|
window.createRelatedCase = async function () {
|
|||
|
|
const titleEl = document.getElementById('newCaseTitle');
|
|||
|
|
const relationTypeEl = document.getElementById('newCaseRelationType');
|
|||
|
|
const descriptionEl = document.getElementById('newCaseDescription');
|
|||
|
|
|
|||
|
|
const title = (titleEl?.value || '').trim();
|
|||
|
|
const relationType = (relationTypeEl?.value || '').trim();
|
|||
|
|
const description = (descriptionEl?.value || '').trim();
|
|||
|
|
|
|||
|
|
if (!title) {
|
|||
|
|
alert('Titel er påkrævet');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!relationType) {
|
|||
|
|
alert('Vælg relationstype');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pathMatch = String(window.location.pathname || '').match(/\/sag\/(\d+)/);
|
|||
|
|
const rootCaseId = pathMatch ? parseInt(pathMatch[1], 10) : null;
|
|||
|
|
if (!rootCaseId) {
|
|||
|
|
alert('Kunne ikke finde sags-id i URL');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let customerId = {{ case.customer_id if case.customer_id else 'null' }};
|
|||
|
|
if (!customerId) {
|
|||
|
|
try {
|
|||
|
|
const rootCaseResponse = await fetch(`/api/v1/sag/${rootCaseId}`, { credentials: 'include' });
|
|||
|
|
if (rootCaseResponse.ok) {
|
|||
|
|
const rootCase = await rootCaseResponse.json();
|
|||
|
|
customerId = rootCase?.customer_id || null;
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
// handled by validation below
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!customerId) {
|
|||
|
|
alert('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const caseResponse = await fetch('/api/v1/sag', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
credentials: 'include',
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
titel: title,
|
|||
|
|
beskrivelse: description,
|
|||
|
|
status: 'åben',
|
|||
|
|
customer_id: customerId
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!caseResponse.ok) {
|
|||
|
|
const payload = await caseResponse.json().catch(() => ({}));
|
|||
|
|
throw new Error(payload.detail || 'Kunne ikke oprette sag');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const newCase = await caseResponse.json();
|
|||
|
|
const newCaseId = Number(newCase?.id || 0);
|
|||
|
|
if (!newCaseId) {
|
|||
|
|
throw new Error('Kunne ikke læse nyt sags-id');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const relationResponse = await fetch(`/api/v1/sag/${rootCaseId}/relationer`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
credentials: 'include',
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
målsag_id: newCaseId,
|
|||
|
|
relationstype: relationType
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!relationResponse.ok) {
|
|||
|
|
const payload = await relationResponse.json().catch(() => ({}));
|
|||
|
|
throw new Error(payload.detail || 'Kunne ikke oprette relation');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.location.reload();
|
|||
|
|
} catch (error) {
|
|||
|
|
alert('Der opstod en fejl: ' + (error?.message || error));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<!-- ── Relation row: quick-actions + inline tags ──────────────────────── -->
|
|||
|
|
<script>
|
|||
|
|
(function () {
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
let _openPopover = null;
|
|||
|
|
|
|||
|
|
// ── helpers ───────────────────────────────────────────────────────
|
|||
|
|
function closeAllPopovers() {
|
|||
|
|
document.querySelectorAll('.rel-qa-menu').forEach(el => el.remove());
|
|||
|
|
_openPopover = null;
|
|||
|
|
}
|
|||
|
|
document.addEventListener('click', function(e) {
|
|||
|
|
if (!e.target.closest('.rel-qa-menu') && !e.target.closest('.btn-rel-action')) closeAllPopovers();
|
|||
|
|
});
|
|||
|
|
document.addEventListener('keydown', function(e) {
|
|||
|
|
if (e.key === 'Escape') closeAllPopovers();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function popoverPos(btn) {
|
|||
|
|
const r = btn.getBoundingClientRect();
|
|||
|
|
return { top: r.bottom + window.scrollY + 4, left: r.left + window.scrollX };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|||
|
|
|
|||
|
|
// ── load global entity tags into rel-tag-row divs (using global tag system) ──
|
|||
|
|
async function loadAllRelationTags() {
|
|||
|
|
const rows = Array.from(document.querySelectorAll('.rel-tag-row'));
|
|||
|
|
if (!rows.length) return;
|
|||
|
|
// Wait briefly for tag-picker.js to initialize
|
|||
|
|
const renderFn = () => window.renderEntityTags;
|
|||
|
|
await new Promise(res => { const t = setInterval(() => { if (renderFn()) { clearInterval(t); res(); } }, 50); setTimeout(() => { clearInterval(t); res(); }, 2000); });
|
|||
|
|
await Promise.all(rows.map(async el => {
|
|||
|
|
const caseId = parseInt(el.id.replace('rel-tags-', ''));
|
|||
|
|
if (isNaN(caseId) || !window.renderEntityTags) return;
|
|||
|
|
await window.renderEntityTags('case', caseId, el.id);
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── tag button → opens global tag picker ──────────────────────────
|
|||
|
|
window.openRelTagPopover = function(caseId) {
|
|||
|
|
if (!window.showTagPicker) return;
|
|||
|
|
window.showTagPicker('case', caseId, () => {
|
|||
|
|
if (window.renderEntityTags) window.renderEntityTags('case', caseId, 'rel-tags-' + caseId);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── quick action menu ─────────────────────────────────────────────
|
|||
|
|
const QA_ITEMS = [
|
|||
|
|
{ icon: 'bi-clock', label: 'Tidregistrering', action: 'time' },
|
|||
|
|
{ icon: 'bi-chat-left-text', label: 'Kommentar', action: 'note' },
|
|||
|
|
{ icon: 'bi-bell', label: 'Påmindelse', action: 'reminder' },
|
|||
|
|
{ icon: 'bi-graph-up-arrow', label: 'Salgspipeline', action: 'pipeline' },
|
|||
|
|
{ icon: 'bi-paperclip', label: 'Filer', action: 'files' },
|
|||
|
|
{ icon: 'bi-cpu', label: 'Hardware', action: 'hardware' },
|
|||
|
|
{ icon: 'bi-check2-square', label: 'Opgave', action: 'todo' },
|
|||
|
|
{ icon: 'bi-lightbulb', label: 'Løsning', action: 'solution' },
|
|||
|
|
{ icon: 'bi-bag', label: 'Varekøb & salg', action: 'sales' },
|
|||
|
|
{ icon: 'bi-arrow-repeat', label: 'Abonnement', action: 'subscription' },
|
|||
|
|
{ icon: 'bi-envelope', label: 'Send email', action: 'email' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// cache pipeline presence per caseId so we only fetch once per page load
|
|||
|
|
const _pipelineCache = {};
|
|||
|
|
|
|||
|
|
window.openRelQaMenu = async function(caseId, caseTitle, btn) {
|
|||
|
|
closeAllPopovers();
|
|||
|
|
btn.classList.add('active');
|
|||
|
|
const pos = popoverPos(btn);
|
|||
|
|
const menu = document.createElement('div');
|
|||
|
|
menu.className = 'rel-qa-menu';
|
|||
|
|
menu.style.cssText = `position:absolute;top:${pos.top}px;left:${Math.max(0, pos.left - 120)}px;`;
|
|||
|
|
menu.innerHTML = `<div style="font-size:.72rem;font-weight:700;color:var(--accent);padding:4px 12px 4px;">SAG-${caseId}</div>`
|
|||
|
|
+ `<div style="font-size:.72rem;color:var(--text-secondary,#aaa);padding:2px 12px 4px;"><span class="spinner-border spinner-border-sm" style="width:.6rem;height:.6rem;border-width:.1em;"></span></div>`;
|
|||
|
|
document.body.appendChild(menu);
|
|||
|
|
_openPopover = menu;
|
|||
|
|
|
|||
|
|
// Fetch case data to check pipeline presence (cached)
|
|||
|
|
if (!(_pipelineCache[caseId] !== undefined)) {
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}`, { credentials: 'include' });
|
|||
|
|
if (r.ok) {
|
|||
|
|
const d = await r.json();
|
|||
|
|
_pipelineCache[caseId] = !!(d.pipeline_stage_id || d.pipeline_amount || d.pipeline_description);
|
|||
|
|
} else {
|
|||
|
|
_pipelineCache[caseId] = false;
|
|||
|
|
}
|
|||
|
|
} catch { _pipelineCache[caseId] = false; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasPipeline = _pipelineCache[caseId];
|
|||
|
|
|
|||
|
|
// Filter: hide pipeline item if case already has one; show "Se pipeline" link instead
|
|||
|
|
const items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline);
|
|||
|
|
const extra = hasPipeline
|
|||
|
|
? `<div class="qa-item" style="opacity:.55;font-style:italic;" onclick="window.open('/sag/${caseId}','_blank')"><i class="bi bi-graph-up-arrow"></i>Pipeline (se sagen)</div>`
|
|||
|
|
: '';
|
|||
|
|
|
|||
|
|
if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
|
|||
|
|
menu.innerHTML = `<div style="font-size:.72rem;font-weight:700;color:var(--accent);padding:4px 12px 4px;">SAG-${caseId}</div>`
|
|||
|
|
+ items.map(item =>
|
|||
|
|
`<div class="qa-item" onclick="relQaAction('${item.action}',${caseId},'${caseTitle.replace(/'/g,"\\'")}')"><i class="bi ${item.icon}"></i>${esc(item.label)}</div>`
|
|||
|
|
).join('')
|
|||
|
|
+ extra;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function getRelQaPrimaryButton() {
|
|||
|
|
const sidePanel = document.getElementById('caseAddSidePanel');
|
|||
|
|
if (sidePanel && sidePanel.classList.contains('open')) {
|
|||
|
|
return document.querySelector('#caseAddSideWorkspace #relQaModalFooter .btn-primary');
|
|||
|
|
}
|
|||
|
|
return document.querySelector('body > #relQaModalEl .btn-primary');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeRelQaSurfaceAfterSave() {
|
|||
|
|
const sidePanel = document.getElementById('caseAddSidePanel');
|
|||
|
|
const panelOpen = !!(sidePanel && sidePanel.classList.contains('open'));
|
|||
|
|
|
|||
|
|
const relModalEl = document.getElementById('relQaModalEl');
|
|||
|
|
const relModalInstance = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null;
|
|||
|
|
if (relModalInstance) {
|
|||
|
|
relModalInstance.hide();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// In sidepanel mode, refresh to reflect new persisted data across modules.
|
|||
|
|
if (panelOpen) {
|
|||
|
|
setTimeout(() => window.location.reload(), 120);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.relQaAction = function(action, caseId, caseTitle) {
|
|||
|
|
closeAllPopovers();
|
|||
|
|
if (action === 'time') openRelTimeModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'email') openRelEmailModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'note') openRelNoteModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'reminder') openRelReminderModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'todo') openRelTodoModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'assign') openRelAssignModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'pipeline') openRelPipelineModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'files') openRelFilesModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'hardware') openRelHardwareModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
|
|||
|
|
else if (action === 'subscription') openRelSubscriptionModal(caseId, caseTitle);
|
|||
|
|
else window.open(`/sag/${caseId}`, '_blank');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Pipeline modal ──────────────────────────────────────────
|
|||
|
|
window.openRelPipelineModal = function(caseId, caseTitle) {
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-graph-up-arrow me-2"></i>Salgspipeline`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Stage</label>
|
|||
|
|
<select id="rqp_stage" class="form-select form-select-sm">
|
|||
|
|
<option value="">-- Vælg stage --</option>
|
|||
|
|
<option value="1">Ny</option>
|
|||
|
|
<option value="2">Afklaring</option>
|
|||
|
|
<option value="3">Tilbud</option>
|
|||
|
|
<option value="4">Commit</option>
|
|||
|
|
<option value="5">Vundet</option>
|
|||
|
|
<option value="6">Tabt</option>
|
|||
|
|
<option value="7">Opsalg</option>
|
|||
|
|
<option value="8">Lead</option>
|
|||
|
|
<option value="9">Kontakt</option>
|
|||
|
|
<option value="10">Forhandling</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="row g-2 mb-2">
|
|||
|
|
<div class="col-7">
|
|||
|
|
<label class="form-label small fw-semibold">Beløb (DKK)</label>
|
|||
|
|
<input type="number" id="rqp_amount" class="form-control form-control-sm" min="0" step="0.01" placeholder="0">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-5">
|
|||
|
|
<label class="form-label small fw-semibold">Sandsynlighed %</label>
|
|||
|
|
<input type="number" id="rqp_prob" class="form-control form-control-sm" min="0" max="100" placeholder="0">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Note</label>
|
|||
|
|
<textarea id="rqp_desc" class="form-control form-control-sm" rows="2" placeholder="Pipeline-note…"></textarea>
|
|||
|
|
</div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelPipeline(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelPipeline = async function(caseId) {
|
|||
|
|
const stage = document.getElementById('rqp_stage').value;
|
|||
|
|
const amount = document.getElementById('rqp_amount').value;
|
|||
|
|
const prob = document.getElementById('rqp_prob').value;
|
|||
|
|
const desc = document.getElementById('rqp_desc').value;
|
|||
|
|
const payload = {};
|
|||
|
|
if (stage) payload.stage_id = parseInt(stage);
|
|||
|
|
if (amount) payload.amount = parseFloat(amount);
|
|||
|
|
if (prob) payload.probability = parseInt(prob);
|
|||
|
|
if (desc) payload.description = desc;
|
|||
|
|
if (!Object.keys(payload).length) { if (typeof showNotification === 'function') showNotification('Udfyld mindst ét felt', 'warning'); return; }
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; }
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/pipeline`, { method: 'PATCH', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Pipeline opdateret ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Files modal ─────────────────────────────────────────────
|
|||
|
|
window.openRelFilesModal = function(caseId, caseTitle) {
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-paperclip me-2"></i>Upload fil`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Vælg fil</label>
|
|||
|
|
<input type="file" id="rqf_file" class="form-control form-control-sm" multiple>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Beskrivelse (valgfri)</label>
|
|||
|
|
<input type="text" id="rqf_desc" class="form-control form-control-sm" placeholder="Fil-note…">
|
|||
|
|
</div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelFiles(${caseId})"><i class="bi bi-upload me-1"></i>Upload</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelFiles = async function(caseId) {
|
|||
|
|
const fileInput = document.getElementById('rqf_file');
|
|||
|
|
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
|
|||
|
|
try {
|
|||
|
|
const fd = new FormData();
|
|||
|
|
for (const file of fileInput.files) {
|
|||
|
|
fd.append('files', file);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
|
|||
|
|
if (!r.ok) {
|
|||
|
|
const d = await r.json().catch(() => ({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Upload fejlede', 'error');
|
|||
|
|
if (saveBtn) {
|
|||
|
|
saveBtn.disabled = false;
|
|||
|
|
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const saved = await r.json().catch(() => []);
|
|||
|
|
const uploadedCount = Array.isArray(saved) ? saved.length : 0;
|
|||
|
|
if (uploadedCount === 0) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Filer blev ikke gemt. Prøv igen.', 'warning');
|
|||
|
|
if (saveBtn) {
|
|||
|
|
saveBtn.disabled = false;
|
|||
|
|
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof loadSagFiles === 'function') {
|
|||
|
|
await loadSagFiles();
|
|||
|
|
}
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') {
|
|||
|
|
showNotification(`${uploadedCount} fil(er) uploadet ✓`, 'success');
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Upload fejlede', 'error');
|
|||
|
|
if (saveBtn) {
|
|||
|
|
saveBtn.disabled = false;
|
|||
|
|
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Hardware modal ──────────────────────────────────────────
|
|||
|
|
window.openRelHardwareModal = async function(caseId, caseTitle) {
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-cpu me-2"></i>Hardware`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Søg hardware</label>
|
|||
|
|
<input type="text" id="rqhw_search" class="form-control form-control-sm" placeholder="Serienummer, navn…" autocomplete="off">
|
|||
|
|
<div id="rqhw_results" class="mt-1" style="max-height:180px;overflow-y:auto;border:1px solid var(--border,#dee2e6);border-radius:6px;display:none;"></div>
|
|||
|
|
</div>
|
|||
|
|
<div id="rqhw_selected" class="text-muted small"></div>
|
|||
|
|
<div class="mb-2 mt-2">
|
|||
|
|
<label class="form-label small fw-semibold">Note (valgfri)</label>
|
|||
|
|
<input type="text" id="rqhw_note" class="form-control form-control-sm" placeholder="Note om hardware…">
|
|||
|
|
</div>
|
|||
|
|
<input type="hidden" id="rqhw_id" value="">`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelHardware(${caseId})"><i class="bi bi-check2 me-1"></i>Tilknyt</button>`
|
|||
|
|
);
|
|||
|
|
// Wire up search
|
|||
|
|
const inp = document.getElementById('rqhw_search');
|
|||
|
|
const res = document.getElementById('rqhw_results');
|
|||
|
|
let _hwTimer;
|
|||
|
|
inp.addEventListener('input', () => {
|
|||
|
|
clearTimeout(_hwTimer);
|
|||
|
|
_hwTimer = setTimeout(async () => {
|
|||
|
|
const q = inp.value.trim();
|
|||
|
|
if (q.length < 2) { res.style.display='none'; return; }
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/search/hardware?q=${encodeURIComponent(q)}`, { credentials: 'include' });
|
|||
|
|
if (!r.ok) return;
|
|||
|
|
const items = await r.json();
|
|||
|
|
if (!items.length) { res.innerHTML = '<div class="p-2 text-muted small">Ingen resultater</div>'; res.style.display='block'; return; }
|
|||
|
|
res.innerHTML = items.slice(0,10).map(h =>
|
|||
|
|
`<div class="p-2 border-bottom hw-opt" style="cursor:pointer;font-size:.82rem;" data-id="${h.id}" data-label="${esc(h.name||h.serial_number||h.id)}">${esc(h.name||'')} <span class="text-muted">${esc(h.serial_number||'')}</span></div>`
|
|||
|
|
).join('');
|
|||
|
|
res.style.display = 'block';
|
|||
|
|
res.querySelectorAll('.hw-opt').forEach(el => el.addEventListener('click', () => {
|
|||
|
|
document.getElementById('rqhw_id').value = el.dataset.id;
|
|||
|
|
document.getElementById('rqhw_selected').textContent = '✓ Valgt: ' + el.dataset.label;
|
|||
|
|
inp.value = el.dataset.label;
|
|||
|
|
res.style.display = 'none';
|
|||
|
|
}));
|
|||
|
|
} catch {}
|
|||
|
|
}, 300);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelHardware = async function(caseId) {
|
|||
|
|
const hwId = document.getElementById('rqhw_id').value;
|
|||
|
|
if (!hwId) { if (typeof showNotification === 'function') showNotification('Vælg hardware fra listen', 'warning'); return; }
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) saveBtn.disabled = true;
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/hardware`, {
|
|||
|
|
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify({ hardware_id: parseInt(hwId), note: document.getElementById('rqhw_note').value })
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Hardware tilknyttet ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Løsning modal ───────────────────────────────────────────
|
|||
|
|
window.openRelSolutionModal = function(caseId, caseTitle) {
|
|||
|
|
const today = new Date().toISOString().split('T')[0];
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-lightbulb me-2"></i>Løsning`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Titel</label>
|
|||
|
|
<input type="text" id="rqs_title" class="form-control form-control-sm" placeholder="Løsningstitel…">
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Type</label>
|
|||
|
|
<select id="rqs_type" class="form-select form-select-sm">
|
|||
|
|
<option value="standard">Standard</option>
|
|||
|
|
<option value="workaround">Workaround</option>
|
|||
|
|
<option value="permanent">Permanent</option>
|
|||
|
|
<option value="external">Ekstern</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Resultat</label>
|
|||
|
|
<select id="rqs_result" class="form-select form-select-sm">
|
|||
|
|
<option value="resolved">Løst</option>
|
|||
|
|
<option value="partial">Delvist løst</option>
|
|||
|
|
<option value="unresolved">Uløst</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Beskrivelse</label>
|
|||
|
|
<textarea id="rqs_desc" class="form-control form-control-sm" rows="3" placeholder="Beskriv løsningen…"></textarea>
|
|||
|
|
</div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelSolution(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelSolution = async function(caseId) {
|
|||
|
|
const title = document.getElementById('rqs_title').value.trim();
|
|||
|
|
if (!title) { if (typeof showNotification === 'function') showNotification('Angiv en titel', 'warning'); return; }
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) saveBtn.disabled = true;
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/solution`, {
|
|||
|
|
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
sag_id: caseId,
|
|||
|
|
title,
|
|||
|
|
solution_type: document.getElementById('rqs_type').value,
|
|||
|
|
result: document.getElementById('rqs_result').value,
|
|||
|
|
description: document.getElementById('rqs_desc').value,
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Løsning gemt ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Varekøb & Salg modal ────────────────────────────────────
|
|||
|
|
window.openRelSalesModal = function(caseId, caseTitle) {
|
|||
|
|
const today = new Date().toISOString().split('T')[0];
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-bag me-2"></i>Varekøb & salg`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Type</label>
|
|||
|
|
<select id="rqsl_type" class="form-select form-select-sm">
|
|||
|
|
<option value="sale">Salg</option>
|
|||
|
|
<option value="purchase">Indkøb</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Beskrivelse</label>
|
|||
|
|
<input type="text" id="rqsl_desc" class="form-control form-control-sm" placeholder="Varebeskrivelse…">
|
|||
|
|
</div>
|
|||
|
|
<div class="row g-2 mb-2">
|
|||
|
|
<div class="col-4">
|
|||
|
|
<label class="form-label small fw-semibold">Antal</label>
|
|||
|
|
<input type="number" id="rqsl_qty" class="form-control form-control-sm" min="1" value="1" step="1">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-4">
|
|||
|
|
<label class="form-label small fw-semibold">Stykpris</label>
|
|||
|
|
<input type="number" id="rqsl_uprice" class="form-control form-control-sm" min="0" step="0.01" placeholder="0.00">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-4">
|
|||
|
|
<label class="form-label small fw-semibold">Total (DKK)</label>
|
|||
|
|
<input type="number" id="rqsl_total" class="form-control form-control-sm" min="0" step="0.01" placeholder="0.00">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Dato</label>
|
|||
|
|
<input type="date" id="rqsl_date" class="form-control form-control-sm" value="${today}">
|
|||
|
|
</div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelSales(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
|
|||
|
|
);
|
|||
|
|
// Auto-calculate total when qty/uprice changes
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const qtyEl = document.getElementById('rqsl_qty');
|
|||
|
|
const uprEl = document.getElementById('rqsl_uprice');
|
|||
|
|
const totEl = document.getElementById('rqsl_total');
|
|||
|
|
function calcTotal() {
|
|||
|
|
const q = parseFloat(qtyEl.value) || 0;
|
|||
|
|
const u = parseFloat(uprEl.value) || 0;
|
|||
|
|
if (q && u) totEl.value = (q * u).toFixed(2);
|
|||
|
|
}
|
|||
|
|
qtyEl.addEventListener('input', calcTotal);
|
|||
|
|
uprEl.addEventListener('input', calcTotal);
|
|||
|
|
}, 50);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelSales = async function(caseId) {
|
|||
|
|
const desc = document.getElementById('rqsl_desc').value.trim();
|
|||
|
|
const total = parseFloat(document.getElementById('rqsl_total').value);
|
|||
|
|
if (!desc) { if (typeof showNotification === 'function') showNotification('Angiv beskrivelse', 'warning'); return; }
|
|||
|
|
if (!total) { if (typeof showNotification === 'function') showNotification('Angiv beløb', 'warning'); return; }
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) saveBtn.disabled = true;
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/sale-items`, {
|
|||
|
|
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
type: document.getElementById('rqsl_type').value,
|
|||
|
|
description: desc,
|
|||
|
|
quantity: parseFloat(document.getElementById('rqsl_qty').value) || 1,
|
|||
|
|
unit_price: parseFloat(document.getElementById('rqsl_uprice').value) || null,
|
|||
|
|
amount: total,
|
|||
|
|
line_date: document.getElementById('rqsl_date').value || null,
|
|||
|
|
status: 'draft',
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Varelinje oprettet ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Abonnement modal ────────────────────────────────────────
|
|||
|
|
window.openRelSubscriptionModal = function(caseId, caseTitle) {
|
|||
|
|
const today = new Date().toISOString().split('T')[0];
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-arrow-repeat me-2"></i>Abonnement`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="row g-2 mb-2">
|
|||
|
|
<div class="col-6">
|
|||
|
|
<label class="form-label small fw-semibold">Faktureringsinterval</label>
|
|||
|
|
<select id="rqsub_interval" class="form-select form-select-sm">
|
|||
|
|
<option value="monthly">Månedlig</option>
|
|||
|
|
<option value="quarterly">Kvartalsvis</option>
|
|||
|
|
<option value="yearly">Årlig</option>
|
|||
|
|
<option value="weekly">Ugentlig</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="col-3">
|
|||
|
|
<label class="form-label small fw-semibold">Fakturering dag</label>
|
|||
|
|
<input type="number" id="rqsub_day" class="form-control form-control-sm" min="1" max="28" value="1">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-3">
|
|||
|
|
<label class="form-label small fw-semibold">Startdato</label>
|
|||
|
|
<input type="date" id="rqsub_start" class="form-control form-control-sm" value="${today}">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="border rounded p-2 mb-2">
|
|||
|
|
<div class="small fw-semibold mb-1">Varelinje</div>
|
|||
|
|
<div class="row g-1">
|
|||
|
|
<div class="col-6"><input type="text" id="rqsub_li_desc" class="form-control form-control-sm" placeholder="Beskrivelse"></div>
|
|||
|
|
<div class="col-3"><input type="number" id="rqsub_li_qty" class="form-control form-control-sm" placeholder="Antal" min="1" value="1"></div>
|
|||
|
|
<div class="col-3"><input type="number" id="rqsub_li_price" class="form-control form-control-sm" placeholder="Pris" min="0" step="0.01"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2">
|
|||
|
|
<label class="form-label small fw-semibold">Note (valgfri)</label>
|
|||
|
|
<input type="text" id="rqsub_notes" class="form-control form-control-sm" placeholder="Intern note…">
|
|||
|
|
</div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelSubscription(${caseId})"><i class="bi bi-check2 me-1"></i>Opret</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelSubscription = async function(caseId) {
|
|||
|
|
const interval = document.getElementById('rqsub_interval').value;
|
|||
|
|
const day = parseInt(document.getElementById('rqsub_day').value);
|
|||
|
|
const startDate = document.getElementById('rqsub_start').value;
|
|||
|
|
const liDesc = document.getElementById('rqsub_li_desc').value.trim();
|
|||
|
|
const liQty = parseFloat(document.getElementById('rqsub_li_qty').value) || 1;
|
|||
|
|
const liPrice = parseFloat(document.getElementById('rqsub_li_price').value) || 0;
|
|||
|
|
if (!startDate) { if (typeof showNotification === 'function') showNotification('Angiv startdato', 'warning'); return; }
|
|||
|
|
if (!liDesc || !liPrice) { if (typeof showNotification === 'function') showNotification('Udfyld varelinje (beskrivelse + pris)', 'warning'); return; }
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) saveBtn.disabled = true;
|
|||
|
|
try {
|
|||
|
|
const r = await fetch('/api/v1/sag-subscriptions', {
|
|||
|
|
method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
sag_id: caseId,
|
|||
|
|
billing_interval: interval,
|
|||
|
|
billing_day: day,
|
|||
|
|
start_date: startDate,
|
|||
|
|
notes: document.getElementById('rqsub_notes').value || null,
|
|||
|
|
line_items: [{ description: liDesc, quantity: liQty, unit_price: liPrice }]
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Abonnement oprettet ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl', 'error');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Time modal ──────────────────────────────────────────────
|
|||
|
|
window.openRelTimeModal = function(caseId, caseTitle) {
|
|||
|
|
const today = new Date().toISOString().split('T')[0];
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-clock me-2"></i>Tidregistrering`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">Sag</label>
|
|||
|
|
<input class="form-control form-control-sm" readonly value="SAG-${caseId} – ${esc(caseTitle)}"></div>
|
|||
|
|
<div class="row g-2 mb-2">
|
|||
|
|
<div class="col-6"><label class="form-label small fw-semibold">Dato</label>
|
|||
|
|
<input type="date" id="rqt_date" class="form-control form-control-sm" value="${today}"></div>
|
|||
|
|
<div class="col-3"><label class="form-label small fw-semibold">Timer</label>
|
|||
|
|
<input type="number" id="rqt_h" class="form-control form-control-sm" min="0" max="23" value="0"></div>
|
|||
|
|
<div class="col-3"><label class="form-label small fw-semibold">Min</label>
|
|||
|
|
<input type="number" id="rqt_m" class="form-control form-control-sm" min="0" max="59" step="15" value="30"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Fakturering</label>
|
|||
|
|
<select id="rqt_billing" class="form-select form-select-sm">
|
|||
|
|
<option value="invoice">Fakturerbar</option>
|
|||
|
|
<option value="internal">Intern</option>
|
|||
|
|
<option value="prepaid">Forudbetalt</option>
|
|||
|
|
</select></div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Beskrivelse</label>
|
|||
|
|
<textarea id="rqt_desc" class="form-control form-control-sm" rows="2"></textarea></div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelTime(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelTime = async function(caseId) {
|
|||
|
|
const h = parseInt(document.getElementById('rqt_h').value) || 0;
|
|||
|
|
const m = parseInt(document.getElementById('rqt_m').value) || 0;
|
|||
|
|
const totalHours = parseFloat((h + m / 60).toFixed(4));
|
|||
|
|
if (totalHours <= 0) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Angiv tid (timer/minutter)', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const billing = document.getElementById('rqt_billing')?.value || 'invoice';
|
|||
|
|
const payload = {
|
|||
|
|
sag_id: caseId,
|
|||
|
|
worked_date: document.getElementById('rqt_date').value,
|
|||
|
|
original_hours: totalHours,
|
|||
|
|
description: document.getElementById('rqt_desc').value,
|
|||
|
|
billing_method: billing,
|
|||
|
|
is_internal: billing === 'internal',
|
|||
|
|
};
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
|
|||
|
|
try {
|
|||
|
|
const r = await fetch('/api/v1/timetracking/entries/internal', { method: 'POST', credentials: 'include', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Tid registreret ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved registrering', 'error');
|
|||
|
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="bi bi-check2 me-1"></i>Gem'; }
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Gem'; } }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Email modal ─────────────────────────────────────────────
|
|||
|
|
window.openRelEmailModal = function(caseId, caseTitle) {
|
|||
|
|
const defaultRecipient = typeof getDefaultCaseRecipient === 'function' ? getDefaultCaseRecipient() : '';
|
|||
|
|
const defaultSubject = `Sag #${caseId}: `;
|
|||
|
|
const attachmentOptions = Array.isArray(sagFilesCache) && sagFilesCache.length
|
|||
|
|
? sagFilesCache
|
|||
|
|
.map((file) => {
|
|||
|
|
const fileId = Number(file.id);
|
|||
|
|
const filename = esc(file.filename || `Fil ${fileId}`);
|
|||
|
|
return `<option value="${fileId}">${filename}</option>`;
|
|||
|
|
})
|
|||
|
|
.join('')
|
|||
|
|
: '<option disabled>Ingen sagsfiler</option>';
|
|||
|
|
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-envelope me-2"></i>Email`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="row g-2 mb-2">
|
|||
|
|
<div class="col-12"><label class="form-label small fw-semibold">Til</label>
|
|||
|
|
<input type="text" id="rqe_to" class="form-control form-control-sm" placeholder="modtager@eksempel.dk" value="${esc(defaultRecipient)}"></div>
|
|||
|
|
<div class="col-6"><label class="form-label small fw-semibold">Cc</label>
|
|||
|
|
<input type="text" id="rqe_cc" class="form-control form-control-sm" placeholder="cc@eksempel.dk"></div>
|
|||
|
|
<div class="col-6"><label class="form-label small fw-semibold">Bcc</label>
|
|||
|
|
<input type="text" id="rqe_bcc" class="form-control form-control-sm" placeholder="bcc@eksempel.dk"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Emne</label>
|
|||
|
|
<input type="text" id="rqe_subject" class="form-control form-control-sm" value="${esc(defaultSubject)}"></div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Vedhaeftninger</label>
|
|||
|
|
<select id="rqe_attachment_ids" class="form-select form-select-sm" multiple>${attachmentOptions}</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Besked</label>
|
|||
|
|
<textarea id="rqe_body" class="form-control form-control-sm" rows="6" placeholder="Skriv besked..."></textarea></div>
|
|||
|
|
<div id="rqe_status" class="small text-muted"></div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelEmail(${caseId})"><i class="bi bi-send me-1"></i>Send email</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelEmail = async function(caseId) {
|
|||
|
|
const toInput = document.getElementById('rqe_to');
|
|||
|
|
const ccInput = document.getElementById('rqe_cc');
|
|||
|
|
const bccInput = document.getElementById('rqe_bcc');
|
|||
|
|
const subjectInput = document.getElementById('rqe_subject');
|
|||
|
|
const bodyInput = document.getElementById('rqe_body');
|
|||
|
|
const attachmentSelect = document.getElementById('rqe_attachment_ids');
|
|||
|
|
const statusEl = document.getElementById('rqe_status');
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
|
|||
|
|
if (!toInput || !subjectInput || !bodyInput || !statusEl) return;
|
|||
|
|
|
|||
|
|
const to = parseEmailField(toInput.value);
|
|||
|
|
const cc = parseEmailField(ccInput?.value || '');
|
|||
|
|
const bcc = parseEmailField(bccInput?.value || '');
|
|||
|
|
const subject = (subjectInput.value || '').trim();
|
|||
|
|
const bodyText = (bodyInput.value || '').trim();
|
|||
|
|
const attachmentFileIds = Array.from(attachmentSelect?.selectedOptions || [])
|
|||
|
|
.map((opt) => Number(opt.value))
|
|||
|
|
.filter((id) => Number.isInteger(id) && id > 0);
|
|||
|
|
|
|||
|
|
if (!to.length) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Udfyld mindst en modtager.', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!subject) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Udfyld emne.', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!bodyText) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Udfyld besked.', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (saveBtn) {
|
|||
|
|
saveBtn.disabled = true;
|
|||
|
|
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender...';
|
|||
|
|
}
|
|||
|
|
statusEl.className = 'small text-muted';
|
|||
|
|
statusEl.textContent = 'Sender e-mail...';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseId}/emails/send`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {'Content-Type': 'application/json'},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
to,
|
|||
|
|
cc,
|
|||
|
|
bcc,
|
|||
|
|
subject,
|
|||
|
|
body_text: bodyText,
|
|||
|
|
attachment_file_ids: attachmentFileIds,
|
|||
|
|
thread_email_id: selectedLinkedEmailId || null,
|
|||
|
|
thread_key: (
|
|||
|
|
linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.resolved_thread_key
|
|||
|
|
|| linkedEmailsCache.find((entry) => Number(entry.id) === Number(selectedLinkedEmailId))?.thread_key
|
|||
|
|
|| null
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
let message = `HTTP ${res.status} ${res.statusText || 'Send failed'}`;
|
|||
|
|
try {
|
|||
|
|
const responseText = await res.text();
|
|||
|
|
if (responseText) {
|
|||
|
|
try {
|
|||
|
|
const err = JSON.parse(responseText);
|
|||
|
|
if (err?.detail) {
|
|||
|
|
message = err.detail;
|
|||
|
|
} else if (err?.message) {
|
|||
|
|
message = err.message;
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
message = responseText.slice(0, 500);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
}
|
|||
|
|
throw new Error(message);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
statusEl.className = 'small text-success';
|
|||
|
|
statusEl.textContent = 'E-mail sendt.';
|
|||
|
|
if (typeof loadLinkedEmails === 'function') {
|
|||
|
|
loadLinkedEmails();
|
|||
|
|
}
|
|||
|
|
if (typeof showNotification === 'function') showNotification('E-mail sendt.', 'success');
|
|||
|
|
|
|||
|
|
const relModalEl = document.getElementById('relQaModalEl');
|
|||
|
|
const relModal = relModalEl ? bootstrap.Modal.getInstance(relModalEl) : null;
|
|||
|
|
if (relModal) relModal.hide();
|
|||
|
|
} catch (error) {
|
|||
|
|
statusEl.className = 'small text-danger';
|
|||
|
|
statusEl.textContent = error?.message || 'Email send failed (ukendt fejl)';
|
|||
|
|
if (typeof showNotification === 'function') showNotification(statusEl.textContent, 'error');
|
|||
|
|
if (saveBtn) {
|
|||
|
|
saveBtn.disabled = false;
|
|||
|
|
saveBtn.innerHTML = '<i class="bi bi-send me-1"></i>Send email';
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (saveBtn) {
|
|||
|
|
saveBtn.disabled = false;
|
|||
|
|
saveBtn.innerHTML = '<i class="bi bi-send me-1"></i>Send email';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Kommentar modal ─────────────────────────────────────────
|
|||
|
|
window.openRelNoteModal = function(caseId, caseTitle) {
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-chat-left-text me-2"></i>Kommentar`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<textarea id="rqn_text" class="form-control" rows="4" placeholder="Skriv kommentar..."></textarea>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelNote(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelNote = async function(caseId) {
|
|||
|
|
const text = document.getElementById('rqn_text').value.trim();
|
|||
|
|
if (!text) return;
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; }
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, {
|
|||
|
|
method: 'POST', credentials: 'include',
|
|||
|
|
headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify({ forfatter: 'Hurtig kommentar', indhold: text })
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Kommentar tilføjet ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved gemning', 'error');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Opgave modal ────────────────────────────────────────────
|
|||
|
|
window.openRelTodoModal = function(caseId, caseTitle) {
|
|||
|
|
const today = new Date().toISOString().split('T')[0];
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-check2-square me-2"></i>Opgave`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Opgavetitel</label>
|
|||
|
|
<input type="text" id="rqtd_title" class="form-control form-control-sm" placeholder="Hvad skal gøres?"></div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Frist (valgfri)</label>
|
|||
|
|
<input type="date" id="rqtd_due" class="form-control form-control-sm" value="${today}"></div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelTodo(${caseId})"><i class="bi bi-check2 me-1"></i>Opret</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelTodo = async function(caseId) {
|
|||
|
|
const title = document.getElementById('rqtd_title').value.trim();
|
|||
|
|
if (!title) { if (typeof showNotification === 'function') showNotification('Angiv opgavetitel', 'warning'); return; }
|
|||
|
|
const due = document.getElementById('rqtd_due').value || null;
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; }
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/todos`, {
|
|||
|
|
method: 'POST', credentials: 'include',
|
|||
|
|
headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify({ titel: title, frist: due, sag_id: caseId })
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Opgave-endpoint ikke tilgængeligt endnu', 'warning');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Tildel sag modal ────────────────────────────────────────
|
|||
|
|
window.openRelAssignModal = async function(caseId, caseTitle) {
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-person-check me-2"></i>Tildel sag`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<label class="form-label small fw-semibold">Ansvarlig bruger</label>
|
|||
|
|
<select id="rqa_user" class="form-select form-select-sm"><option>Henter brugere…</option></select>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelAssign(${caseId})"><i class="bi bi-check2 me-1"></i>Tildel</button>`
|
|||
|
|
);
|
|||
|
|
try {
|
|||
|
|
const r = await fetch('/api/v1/users', { credentials: 'include' });
|
|||
|
|
if (r.ok) {
|
|||
|
|
const users = await r.json();
|
|||
|
|
const sel = document.getElementById('rqa_user');
|
|||
|
|
if (sel) sel.innerHTML = '<option value="">Ingen (fjern tildeling)</option>'
|
|||
|
|
+ users.map(u => `<option value="${u.user_id}">${esc(u.display_name || u.username || '')}</option>`).join('');
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelAssign = async function(caseId) {
|
|||
|
|
const userId = document.getElementById('rqa_user')?.value;
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; }
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}`, {
|
|||
|
|
method: 'PATCH', credentials: 'include',
|
|||
|
|
headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify({ ansvarlig_bruger_id: userId ? parseInt(userId) : null })
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Sag tildelt ✓', 'success');
|
|||
|
|
} else {
|
|||
|
|
const d = await r.json().catch(()=>({}));
|
|||
|
|
if (typeof showNotification === 'function') showNotification(d.detail || 'Fejl ved tildeling', 'error');
|
|||
|
|
if (saveBtn) saveBtn.disabled = false;
|
|||
|
|
}
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Quick Reminder modal ──────────────────────────────────────────
|
|||
|
|
window.openRelReminderModal = function(caseId, caseTitle) {
|
|||
|
|
const tmr = new Date(); tmr.setDate(tmr.getDate()+1);
|
|||
|
|
const tmrStr = tmr.toISOString().slice(0,16);
|
|||
|
|
_showRelModal(
|
|||
|
|
`<i class="bi bi-bell me-2"></i>Påmindelse`,
|
|||
|
|
`<div class="mb-2"><label class="form-label small fw-semibold">Sag: SAG-${caseId} – ${esc(caseTitle)}</label></div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Tidspunkt</label>
|
|||
|
|
<input type="datetime-local" id="rqr_at" class="form-control form-control-sm" value="${tmrStr}"></div>
|
|||
|
|
<div class="mb-2"><label class="form-label small fw-semibold">Besked</label>
|
|||
|
|
<input type="text" id="rqr_msg" class="form-control form-control-sm" placeholder="Husk at…"></div>`,
|
|||
|
|
`<button class="btn btn-sm btn-primary" onclick="_submitRelReminder(${caseId})"><i class="bi bi-check2 me-1"></i>Gem</button>`
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window._submitRelReminder = async function(caseId) {
|
|||
|
|
const whenInput = document.getElementById('rqr_at')?.value || '';
|
|||
|
|
const message = (document.getElementById('rqr_msg')?.value || '').trim();
|
|||
|
|
if (!whenInput) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Vælg tidspunkt', 'warning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let reminderUserId = null;
|
|||
|
|
try {
|
|||
|
|
if (typeof ensureReminderUserId === 'function') {
|
|||
|
|
reminderUserId = await ensureReminderUserId();
|
|||
|
|
} else if (typeof getReminderUserId === 'function') {
|
|||
|
|
reminderUserId = getReminderUserId();
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
if (!reminderUserId) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Mangler bruger-id til reminder', 'error');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const scheduledAtIso = new Date(whenInput).toISOString();
|
|||
|
|
const baseTitle = (message || 'Husk opfoelgning').trim();
|
|||
|
|
const safeTitle = (baseTitle.length >= 3 ? baseTitle : 'Reminder').slice(0, 255);
|
|||
|
|
const payload = {
|
|||
|
|
title: safeTitle,
|
|||
|
|
message: message || null,
|
|||
|
|
priority: 'normal',
|
|||
|
|
event_type: 'reminder',
|
|||
|
|
trigger_type: 'time_based',
|
|||
|
|
trigger_config: {},
|
|||
|
|
recipient_user_ids: [Number(reminderUserId)],
|
|||
|
|
recipient_emails: [],
|
|||
|
|
notify_mattermost: false,
|
|||
|
|
notify_email: false,
|
|||
|
|
notify_frontend: true,
|
|||
|
|
override_user_preferences: false,
|
|||
|
|
recurrence_type: 'once',
|
|||
|
|
recurrence_day_of_week: null,
|
|||
|
|
recurrence_day_of_month: null,
|
|||
|
|
scheduled_at: scheduledAtIso,
|
|||
|
|
};
|
|||
|
|
const saveBtn = getRelQaPrimaryButton();
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; }
|
|||
|
|
try {
|
|||
|
|
const r = await fetch(`/api/v1/sag/${caseId}/reminders?user_id=${encodeURIComponent(reminderUserId)}`, {
|
|||
|
|
method: 'POST', credentials: 'include',
|
|||
|
|
headers: {'Content-Type':'application/json'},
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
if (r.ok) {
|
|||
|
|
closeRelQaSurfaceAfterSave();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Påmindelse oprettet', 'success');
|
|||
|
|
} else { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
} catch { if (saveBtn) saveBtn.disabled = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── shared modal helper ───────────────────────────────────────────
|
|||
|
|
window._showRelModal = function(title, bodyHtml, footerBtns) {
|
|||
|
|
let el = document.querySelector('body > #relQaModalEl.modal');
|
|||
|
|
if (!el) {
|
|||
|
|
const nonModal = document.getElementById('relQaModalEl');
|
|||
|
|
if (nonModal && !nonModal.classList.contains('modal')) {
|
|||
|
|
nonModal.remove();
|
|||
|
|
}
|
|||
|
|
el = document.createElement('div');
|
|||
|
|
el.id = 'relQaModalEl';
|
|||
|
|
el.className = 'modal fade';
|
|||
|
|
el.tabIndex = -1;
|
|||
|
|
el.innerHTML = `<div class="modal-dialog modal-dialog-centered"><div class="modal-content">
|
|||
|
|
<div class="modal-header py-2 px-3">
|
|||
|
|
<h6 class="modal-title mb-0" id="relQaModalTitle"></h6>
|
|||
|
|
<button type="button" class="btn-close btn-sm" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body" id="relQaModalBody"></div>
|
|||
|
|
<div class="modal-footer py-2 px-3" id="relQaModalFooter">
|
|||
|
|
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
|||
|
|
</div>
|
|||
|
|
</div></div>`;
|
|||
|
|
document.body.appendChild(el);
|
|||
|
|
}
|
|||
|
|
document.getElementById('relQaModalTitle').innerHTML = title;
|
|||
|
|
document.getElementById('relQaModalBody').innerHTML = bodyHtml;
|
|||
|
|
const footer = document.getElementById('relQaModalFooter');
|
|||
|
|
// Remove old action buttons (keep Annuller)
|
|||
|
|
footer.querySelectorAll('.btn-primary').forEach(b => b.remove());
|
|||
|
|
if (footerBtns) footer.insertAdjacentHTML('afterbegin', footerBtns);
|
|||
|
|
new bootstrap.Modal(el).show();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── init on page load ─────────────────────────────────────────────
|
|||
|
|
document.addEventListener('DOMContentLoaded', loadAllRelationTags);
|
|||
|
|
|
|||
|
|
})();
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<!-- ── Beskrivelse inline editor ───────────────────────────────────────── -->
|
|||
|
|
<script>
|
|||
|
|
(function () {
|
|||
|
|
const SAG_ID = {{ case.id }};
|
|||
|
|
let _historyLoaded = false;
|
|||
|
|
|
|||
|
|
window.rewriteCaseDescriptionWithApproval = async function () {
|
|||
|
|
const ta = document.getElementById('beskrivelse-textarea');
|
|||
|
|
const rewriteBtn = document.getElementById('beskrivelse-rewrite-btn');
|
|||
|
|
if (!ta) return;
|
|||
|
|
|
|||
|
|
const source = (ta.value || '').trim();
|
|||
|
|
if (!source) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Skriv en beskrivelse først', 'warning');
|
|||
|
|
else alert('Skriv en beskrivelse først');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const originalHtml = rewriteBtn?.innerHTML || '';
|
|||
|
|
if (rewriteBtn) {
|
|||
|
|
rewriteBtn.disabled = true;
|
|||
|
|
rewriteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const rewriteEndpoints = ['/api/v1/rewrite-text', '/api/v1/sag/rewrite-text', '/api/v1/emails/rewrite-text'];
|
|||
|
|
let payload = null;
|
|||
|
|
let lastError = null;
|
|||
|
|
|
|||
|
|
for (const endpoint of rewriteEndpoints) {
|
|||
|
|
const response = await fetch(endpoint, {
|
|||
|
|
method: 'POST',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ text: source, context: 'case' })
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
payload = await response.json();
|
|||
|
|
lastError = null;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let detail = `HTTP ${response.status}`;
|
|||
|
|
try {
|
|||
|
|
const err = await response.json();
|
|||
|
|
if (err?.detail) detail = err.detail;
|
|||
|
|
} catch (_) {}
|
|||
|
|
|
|||
|
|
lastError = new Error(detail);
|
|||
|
|
|
|||
|
|
// Retry next endpoint for common route mismatch cases.
|
|||
|
|
if (![404, 405].includes(response.status)) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!payload) {
|
|||
|
|
throw lastError || new Error('Kunne ikke hente renskrivningsforslag');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rewrittenRaw = String(payload?.rewritten_text || '').trim();
|
|||
|
|
const descMatch = rewrittenRaw.match(/(?:^|\n)Beskrivelse:\s*\n([\s\S]*)$/i);
|
|||
|
|
const rewritten = descMatch?.[1] ? descMatch[1].trim() : rewrittenRaw;
|
|||
|
|
|
|||
|
|
openRewriteReviewModal({
|
|||
|
|
title: 'Sagsbeskrivelse',
|
|||
|
|
originalText: source,
|
|||
|
|
rewrittenText: rewritten,
|
|||
|
|
applyToTarget: (nextText) => {
|
|||
|
|
ta.value = nextText;
|
|||
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('rewritePreviewModal')).hide();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Kunne ikke renskrive beskrivelse', 'error');
|
|||
|
|
else alert(`Kunne ikke renskrive beskrivelse: ${e.message || 'Ukendt fejl'}`);
|
|||
|
|
} finally {
|
|||
|
|
if (rewriteBtn) {
|
|||
|
|
rewriteBtn.disabled = false;
|
|||
|
|
rewriteBtn.innerHTML = originalHtml;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Sag titel inline edit ────────────────────────────────────────────
|
|||
|
|
window.startTitelEdit = function () {
|
|||
|
|
document.getElementById('sag-titel-view').classList.add('d-none');
|
|||
|
|
document.getElementById('sag-titel-editor').classList.remove('d-none');
|
|||
|
|
const input = document.getElementById('sag-titel-input');
|
|||
|
|
input.focus();
|
|||
|
|
input.select();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.cancelTitelEdit = function () {
|
|||
|
|
document.getElementById('sag-titel-editor').classList.add('d-none');
|
|||
|
|
document.getElementById('sag-titel-view').classList.remove('d-none');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.saveTitelEdit = async function () {
|
|||
|
|
const input = document.getElementById('sag-titel-input');
|
|||
|
|
const saveBtn = document.getElementById('sag-titel-save-btn');
|
|||
|
|
const newVal = input.value.trim();
|
|||
|
|
if (!newVal) { alert('Overskrift må ikke være tom.'); return; }
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Gemmer...'; }
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${caseIds}`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ titel: newVal })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) throw new Error((await res.json()).detail || 'Fejl');
|
|||
|
|
document.getElementById('sag-titel-text').textContent = newVal;
|
|||
|
|
document.title = newVal;
|
|||
|
|
// update narrative-lead if present
|
|||
|
|
const lead = document.querySelector('.narrative-lead');
|
|||
|
|
if (lead) lead.textContent = newVal;
|
|||
|
|
cancelTitelEdit();
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Overskrift gemt', 'success');
|
|||
|
|
} catch (e) {
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Kunne ikke gemme overskrift: ' + e.message, 'error');
|
|||
|
|
else alert('Fejl: ' + e.message);
|
|||
|
|
} finally {
|
|||
|
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="bi bi-check2 me-1"></i>Gem'; }
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Enter key closes titel edit ──────────────────────────────────────
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
const inp = document.getElementById('sag-titel-input');
|
|||
|
|
if (inp) {
|
|||
|
|
inp.addEventListener('keydown', e => {
|
|||
|
|
if (e.key === 'Enter') { e.preventDefault(); saveTitelEdit(); }
|
|||
|
|
if (e.key === 'Escape') cancelTitelEdit();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
window.startBeskrivelsEdit = function () {
|
|||
|
|
const current = document.getElementById('beskrivelse-text').innerText.trim();
|
|||
|
|
document.getElementById('beskrivelse-textarea').value = current;
|
|||
|
|
document.getElementById('beskrivelse-view').classList.add('d-none');
|
|||
|
|
document.getElementById('beskrivelse-edit-btn')?.classList.add('d-none');
|
|||
|
|
document.getElementById('beskrivelse-editor').classList.remove('d-none');
|
|||
|
|
document.getElementById('beskrivelse-textarea').focus();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.cancelBeskrivelsEdit = function () {
|
|||
|
|
document.getElementById('beskrivelse-editor').classList.add('d-none');
|
|||
|
|
document.getElementById('beskrivelse-view').classList.remove('d-none');
|
|||
|
|
document.getElementById('beskrivelse-edit-btn')?.classList.remove('d-none');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.saveBeskrivelsEdit = async function () {
|
|||
|
|
const ta = document.getElementById('beskrivelse-textarea');
|
|||
|
|
const saveBtn = document.getElementById('beskrivelse-save-btn');
|
|||
|
|
const newVal = ta.value;
|
|||
|
|
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Gemmer...'; }
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${SAG_ID}/beskrivelse`, {
|
|||
|
|
method: 'PATCH',
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ beskrivelse: newVal })
|
|||
|
|
});
|
|||
|
|
if (!res.ok) throw new Error(await res.text());
|
|||
|
|
const data = await res.json();
|
|||
|
|
// Update view
|
|||
|
|
const textEl = document.getElementById('beskrivelse-text');
|
|||
|
|
textEl.innerText = data.beskrivelse || '';
|
|||
|
|
const emptyEl = document.getElementById('beskrivelse-empty');
|
|||
|
|
if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : '';
|
|||
|
|
cancelBeskrivelsEdit();
|
|||
|
|
// Keep history cache stale so timeline tab reflects latest description edits.
|
|||
|
|
_historyLoaded = false;
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Beskrivelse gemt', 'success');
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
if (typeof showNotification === 'function') showNotification('Kunne ikke gemme beskrivelse', 'error');
|
|||
|
|
} finally {
|
|||
|
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="bi bi-check2 me-1"></i>Gem'; }
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.loadBeskrivelsHistory = async function () {
|
|||
|
|
if (_historyLoaded) return;
|
|||
|
|
const list = document.getElementById('beskrivelse-history-list');
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/v1/sag/${SAG_ID}/beskrivelse/history`, { credentials: 'include' });
|
|||
|
|
if (!res.ok) throw new Error('failed');
|
|||
|
|
const rows = await res.json();
|
|||
|
|
_historyLoaded = true;
|
|||
|
|
const label = document.getElementById('beskrivelse-history-label');
|
|||
|
|
const countBadge = document.getElementById('beskrivelse-history-count');
|
|||
|
|
if (!rows.length) {
|
|||
|
|
if (label) label.textContent = 'Historik';
|
|||
|
|
if (countBadge) countBadge.textContent = '0';
|
|||
|
|
list.innerHTML = '<div class="list-group-item text-muted text-center py-2 small">Ingen historik endnu.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (label) label.textContent = 'Historik';
|
|||
|
|
if (countBadge) countBadge.textContent = String(rows.length);
|
|||
|
|
const esc = s => String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|||
|
|
const trunc = (s, n) => s && s.length > n ? s.substring(0, n) + '…' : (s || '');
|
|||
|
|
list.innerHTML = rows.map(h => {
|
|||
|
|
const d = new Date(h.changed_at);
|
|||
|
|
const when = d.toLocaleDateString('da-DK', {day:'2-digit',month:'2-digit',year:'numeric'})
|
|||
|
|
+ ' ' + d.toLocaleTimeString('da-DK', {hour:'2-digit',minute:'2-digit'});
|
|||
|
|
const who = esc(h.changed_by_name || 'Ukendt');
|
|||
|
|
const before = h.beskrivelse_before ? esc(trunc(h.beskrivelse_before, 150)) : '<em class="text-muted">tom</em>';
|
|||
|
|
const after = h.beskrivelse_after ? esc(trunc(h.beskrivelse_after, 150)) : '<em class="text-muted">tom</em>';
|
|||
|
|
return `<div class="list-group-item px-3 py-2">
|
|||
|
|
<div class="d-flex justify-content-between mb-1">
|
|||
|
|
<span class="fw-semibold small">${who}</span>
|
|||
|
|
<span class="text-muted small">${when}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="d-flex gap-3" style="font-size:.85rem">
|
|||
|
|
<div style="flex:1"><span class="badge text-bg-danger me-1" style="font-size:.7rem">Før</span>${before}</div>
|
|||
|
|
<div style="flex:1"><span class="badge text-bg-success me-1" style="font-size:.7rem">Efter</span>${after}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
}).join('');
|
|||
|
|
} catch (e) {
|
|||
|
|
list.innerHTML = '<div class="list-group-item text-muted text-center py-2 small">Kunne ikke indlæse historik.</div>';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Keyboard shortcuts
|
|||
|
|
document.addEventListener('keydown', function (e) {
|
|||
|
|
const editor = document.getElementById('beskrivelse-editor');
|
|||
|
|
if (!editor || editor.classList.contains('d-none')) return;
|
|||
|
|
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); saveBeskrivelsEdit(); }
|
|||
|
|
if (e.key === 'Escape') { e.preventDefault(); cancelBeskrivelsEdit(); }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// History in details tab removed; unified history lives in the dedicated Historik tab.
|
|||
|
|
})();
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
{% endblock %}
|