bmc_hub/app/modules/sag/templates/detail.html

8610 lines
412 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}{{ case.titel }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.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);
}
.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-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--accent);
background: rgba(15, 76, 117, 0.1);
padding: 2px 6px;
border-radius: 4px;
margin-right: 8px;
font-weight: 500;
}
.right-modules-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.right-modules-grid .card-header {
padding: 0.35rem 0.6rem;
}
.right-modules-grid .card-header h6 {
font-size: 0.8rem;
}
.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: 1.3fr 1fr auto;
}
.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;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem;">
<!-- Top Bar: Back Link + Global Tags -->
<div class="d-flex justify-content-between align-items-start mb-2">
<a href="/sag" class="back-link">
<i class="bi bi-chevron-left"></i> Tilbage til sager
</a>
<!-- Global Tags Area -->
<div class="d-flex align-items-center p-2 rounded" style="background: rgba(0,0,0,0.02);">
<i class="bi bi-tags text-muted me-2 small"></i>
<div id="case-tags" class="d-flex flex-wrap justify-content-end gap-1 align-items-center">
<span class="spinner-border spinner-border-sm text-muted"></span>
</div>
<button class="btn btn-sm btn-link text-decoration-none ms-1 px-1 py-0 text-muted hover-primary"
onclick="window.showTagPicker('case', {{ case.id }}, () => window.renderEntityTags('case', {{ case.id }}, 'case-tags'))"
title="Tilføj tag">
<i class="bi bi-plus-lg"></i>
</button>
<button class="btn btn-sm btn-link text-decoration-none ms-1 px-1 py-0 text-muted hover-primary"
onclick="openModuleControlModal()"
title="Vis/skjul moduler">
<i class="bi bi-sliders"></i>
</button>
</div>
</div>
{% 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) %}
<!-- ═══════════════ PREMIUM CASE HEADER ═══════════════ -->
<div class="case-hero mb-4">
<!-- ── Row 1: Identity stripe ── -->
<div class="case-hero-identity">
<!-- Left cluster: ID + status -->
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="case-id-chip" style="--tcolor:{{ tcolor }};">#{{ case.id }}</span>
<span class="case-status-chip {{ 'open' if case.status == 'åben' else 'closed' }}">
<span class="case-status-dot"></span>{{ case.status|capitalize }}
</span>
</div>
<!-- Right cluster: dates -->
<div class="d-flex align-items-center gap-3">
<span class="case-date-item">
<i class="bi bi-plus-circle"></i>
<span>{{ case.created_at.strftime('%d. %b %Y') if case.created_at else '—' }}</span>
</span>
<span class="case-date-sep">·</span>
<span class="case-date-item">
<i class="bi bi-arrow-repeat"></i>
<span>{{ case.updated_at.strftime('%d. %b %Y') if case.updated_at else '—' }}</span>
</span>
</div>
</div>
<!-- ── Row 2: Meta fields ── -->
<div class="case-hero-meta">
<!-- Kunde -->
<div class="case-meta-cell">
<div class="case-meta-label"><i class="bi bi-building"></i>Kunde</div>
{% if customer %}
<a href="/customers/{{ customer.id }}" class="case-meta-value case-meta-link" style="color:{{ tcolor }}; font-size:1.0rem; font-weight:700;">{{ customer.name }}</a>
{% else %}
<span class="case-meta-value text-muted fst-italic">Ingen</span>
{% endif %}
</div>
<div class="case-meta-divider"></div>
<!-- Kontakt -->
<div class="case-meta-cell" onclick="showKontaktModal()" style="cursor:pointer;" title="Se kontaktinfo">
<div class="case-meta-label"><i class="bi bi-person"></i>Kontakt</div>
{% if hovedkontakt %}
<span class="case-meta-value case-meta-link">{{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }}</span>
{% else %}
<span class="case-meta-value text-muted fst-italic">Ingen</span>
{% endif %}
</div>
<div class="case-meta-divider"></div>
<!-- Afdeling -->
<div class="case-meta-cell" onclick="showAfdelingModal()" style="cursor:pointer;" title="Ændre afdeling">
<div class="case-meta-label"><i class="bi bi-diagram-3"></i>Afdeling</div>
<span class="case-meta-value case-meta-link">{{ customer.department if customer and customer.department else '—' }}</span>
</div>
<div class="case-meta-divider"></div>
<!-- Type -->
<div class="case-meta-cell">
<div class="case-meta-label"><i class="bi bi-tag"></i>Type</div>
<div class="dropdown mt-1" id="caseTypeDropdownWrap">
<button class="case-type-chip border-0 dropdown-toggle" id="caseTypeDropdownBtn"
style="--tcolor:{{ tcolor }};cursor:pointer;"
data-bs-toggle="dropdown" aria-expanded="false" title="Skift type">
<i class="bi {{ ticon }}" id="caseTypeIcon"></i><span id="caseTypeLabel">{{ tlabel }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="min-width:170px;">
<li><h6 class="dropdown-header" style="font-size:.7rem;">Skift sagstype</h6></li>
{% for tval, tlbl, tico, tclr in [
('ticket', 'Ticket', 'bi-ticket-perforated', '#6366f1'),
('pipeline','Pipeline', 'bi-graph-up-arrow', '#0ea5e9'),
('opgave', 'Opgave', 'bi-puzzle', '#f59e0b'),
('ordre', 'Ordre', 'bi-receipt', '#10b981'),
('projekt', 'Projekt', 'bi-folder2-open', '#8b5cf6'),
('service', 'Service', 'bi-tools', '#ef4444')
] %}
<li>
<button class="dropdown-item d-flex align-items-center gap-2 {% if tkey == tval %}fw-semibold{% endif %}"
onclick="saveCaseType('{{ tval }}','{{ tlbl }}','{{ tico }}','{{ tclr }}')"
style="font-size:.875rem;">
<i class="bi {{ tico }}" style="color:{{ tclr }};"></i>{{ tlbl }}
{% if tkey == tval %}<i class="bi bi-check ms-auto"></i>{% endif %}
</button>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="case-meta-divider"></div>
<!-- Ansvarlig -->
<div class="case-meta-cell" style="flex:0 0 auto;">
<div class="case-meta-label"><i class="bi bi-person-check"></i>Ansvarlig</div>
<div class="d-flex gap-2 flex-wrap mt-1">
<select id="assignmentUserSelect" class="case-inline-select" 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" class="case-inline-select" 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 class="case-meta-divider"></div>
<!-- Deadline -->
<div class="case-meta-cell">
<div class="case-meta-label"><i class="bi bi-clock"></i>Deadline</div>
<div class="d-flex align-items-center gap-1 mt-1">
{% if case.deadline %}
<span class="case-date-badge {{ 'overdue' if is_deadline_overdue else '' }}">
<i class="bi {{ 'bi-exclamation-circle-fill' if is_deadline_overdue else 'bi-calendar-check' }}"></i>
{{ case.deadline.strftime('%d/%m/%Y') }}
</span>
{% else %}
<span class="case-meta-empty">Ingen deadline</span>
{% endif %}
<button class="case-edit-btn" onclick="openDeadlineModal()" title="Rediger"><i class="bi bi-pencil-square"></i></button>
</div>
</div>
<div class="case-meta-divider"></div>
<!-- Udsat til -->
<div class="case-meta-cell">
<div class="case-meta-label"><i class="bi bi-calendar-event"></i>Udsat til</div>
<div class="d-flex align-items-center gap-1 mt-1">
{% if case.deferred_until %}
<span class="case-date-badge deferred">
<i class="bi bi-hourglass-split"></i>
{{ case.deferred_until.strftime('%d/%m/%Y') }}
</span>
{% else %}
<span class="case-meta-empty">Ikke udsat</span>
{% endif %}
<button class="case-edit-btn" onclick="openDeferredModal()" title="Rediger"><i class="bi bi-pencil-square"></i></button>
</div>
</div>
</div>
</div>
<!-- ═══════════════ END CASE HEADER ═══════════════ -->
<!-- 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
</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
{% 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
</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
</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
</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
</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-9 col-lg-8" id="case-left-column">
<div class="row g-4">
<!-- TREDELT-1: Relations, History, etc. -->
<div class="col-xl-4 order-2 order-xl-1" id="inner-left-col">
<div class="mb-3"><div class="card h-100 d-flex flex-column right-module-card" data-module="locations" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📍 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>
<div class="mb-3"><div class="card h-100 d-flex flex-column right-module-card" 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="mb-0" style="color: var(--accent);">🏢 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>
<div class="mb-3"><div class="card h-100 d-flex flex-column right-module-card" 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="mb-0" style="color: var(--accent);">👥 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('"', '&quot;') }}"
data-title="{{ contact.title|default('', true)|replace('"', '&quot;') }}"
data-company="{{ contact.customer_name|default('', true)|replace('"', '&quot;') }}"
data-email="{{ contact.contact_email|default('', true)|replace('"', '&quot;') }}"
data-phone="{{ contact.phone|default('', true)|replace('"', '&quot;') }}"
data-mobile="{{ contact.mobile|default('', true)|replace('"', '&quot;') }}"
data-role="{{ contact.role|default('Kontakt')|replace('"', '&quot;') }}"
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>
</div>
<!-- TREDELT-2: Hero, Info -->
<div class="col-xl-8 order-1 order-xl-2" id="inner-center-col">
<!-- ROW 1: Main Info -->
<div class="row mb-3">
<!-- MAIN HERO CARD: Titel & Beskrivelse -->
<div class="col-12 mb-4 mt-2">
<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-4 pt-4 pb-5 position-relative">
<div class="d-flex justify-content-between align-items-start mb-4">
<div class="w-100 pe-3">
<h2 class="mb-2 fw-bolder" style="color: var(--accent); font-size: 1.8rem; letter-spacing: -0.5px;">
{{ case.titel }}
</h2>
<div class="d-flex align-items-center gap-2 mb-1 mt-2">
<span class="badge {{ 'bg-success' if case.status == 'åben' else 'bg-secondary' }} px-2 py-1 shadow-sm">{{ case.status }}</span>
<span class="badge bg-light text-dark border px-2 py-1">{{ case.template_key or case.type or 'ticket' }}</span>
</div>
</div>
<div class="d-flex gap-2 flex-shrink-0 mt-1">
<a href="/sag/{{ case.id }}/edit" class="btn btn-outline-primary shadow-sm" style="border-radius: 6px;">
<i class="bi bi-pencil me-1"></i>Rediger sag
</a>
<button onclick="confirmDeleteCase()" class="btn btn-outline-danger shadow-sm" style="border-radius: 6px;" title="Slet sag">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="mt-4 pt-3 border-top border-light" id="beskrivelse-section">
<!-- Header -->
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="d-flex align-items-center">
<i class="bi bi-card-text fs-5 text-muted me-2"></i>
<h6 class="text-muted text-uppercase small mb-0 fw-bold" style="letter-spacing: 0.05em;">Opgavebeskrivelse</h6>
</div>
<button id="beskrivelse-edit-btn" class="btn btn-sm btn-outline-secondary" onclick="startBeskrivelsEdit()" title="Rediger beskrivelse">
<i class="bi bi-pencil me-1"></i>Rediger
</button>
</div>
<!-- View mode -->
<div id="beskrivelse-view" class="description-section rounded p-4 shadow-sm border" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
<div id="beskrivelse-text" class="prose text-dark" style="font-size: 1.05rem; line-height: 1.7; 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>Klik Rediger eller dobbeltklik for at tilføje</span>
</div>
{% endif %}
</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-save-btn" class="btn btn-sm btn-primary" onclick="saveBeskrivelsEdit()">
<i class="bi bi-check2 me-1"></i>Gem
</button>
</div>
</div>
</div>
<!-- History accordion -->
<div id="beskrivelse-history-wrap" class="mt-3 d-none">
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
type="button" data-bs-toggle="collapse" data-bs-target="#beskrivelse-history-collapse"
onclick="loadBeskrivelsHistory()">
<i class="bi bi-clock-history me-1"></i><span id="beskrivelse-history-label">Historik</span>
</button>
<div class="collapse mt-2" id="beskrivelse-history-collapse">
<div id="beskrivelse-history-list" class="list-group list-group-flush small border rounded">
<div class="list-group-item text-muted text-center py-3">
<span class="spinner-border spinner-border-sm me-1"></span> Indlæser...
</div>
</div>
</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 ringOutFromCase(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
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();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
</script>
<!-- Relationer (center) -->
<div class="row mb-3">
<div class="col-12 mb-3">
<div class="card h-100 d-flex flex-column" 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="mb-0" style="color: var(--accent);">🔗 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_tree(nodes) %}
<ul class="relation-tree">
{% for node in nodes %}
<li class="relation-node">
<div class="d-flex align-items-center py-1">
<div class="relation-node-card w-100 rounded px-2 pt-1 pb-1"
data-case-id="{{ node.case.id }}"
data-case-title="{{ node.case.titel | e }}"
{% if node.is_current %}
style="border-left: 3px solid var(--accent,#0f4c75); background: rgba(15,76,117,0.06);"
{% endif %}>
<!-- Top row: type badge + link + status + actions -->
<div class="d-flex align-items-center" style="min-height:32px;">
<!-- Relation Type Icon/Badge -->
{% if node.relation_type %}
{% set rel_icon = 'bi-link-45deg' %}
{% set rel_color = 'text-muted' %}
{% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
{% if node.relation_type == 'Afledt af' %}
{% set rel_icon = 'bi-arrow-return-right' %}
{% set rel_color = 'text-info' %}
{% set rel_help = 'Denne sag er opstået på baggrund af en anden sag' %}
{% elif node.relation_type == 'Årsag til' %}
{% set rel_icon = 'bi-arrow-right-circle' %}
{% set rel_color = 'text-primary' %}
{% set rel_help = 'Denne sag er årsagen til en anden sag' %}
{% elif node.relation_type == 'Blokkerer' %}
{% set rel_icon = 'bi-slash-circle' %}
{% set rel_color = 'text-danger' %}
{% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
{% endif %}
<span class="relation-type-badge {{ rel_color }}" title="{{ node.relation_type }}: {{ rel_help }}">
<i class="bi {{ rel_icon }}"></i>
<span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span>
</span>
{% endif %}
<!-- Case Link -->
{% if node.is_current %}
<span class="d-flex align-items-center text-truncate" style="pointer-events:none;flex:1;min-width:0;">
<span class="badge me-2 rounded-pill fw-bold" style="font-size:0.65rem;background:var(--accent,#0f4c75);color:#fff;white-space:nowrap;">&#9654; #{{ node.case.id }}</span>
<span class="text-truncate fw-semibold" style="color:var(--accent,#0f4c75);">{{ node.case.titel }}</span>
</span>
{% else %}
<a href="/sag/{{ node.case.id }}" class="text-decoration-none d-flex align-items-center text-secondary text-truncate" style="flex:1;min-width:0;">
<span class="badge bg-secondary me-2 rounded-pill" style="font-size: 0.65rem; opacity: 0.8;">#{{ node.case.id }}</span>
<span class="text-truncate">{{ node.case.titel }}</span>
</a>
{% endif %}
<!-- Status Dot -->
<span class="status-dot status-{{ node.case.status }} ms-2" title="{{ node.case.status }}"></span>
<!-- Duplicate/Reference Indicator -->
{% if node.is_repeated %}
<span class="text-muted ms-2" title="Denne sag vises også et andet sted i træet"><i class="bi bi-arrow-repeat"></i></span>
{% endif %}
<!-- Quick action buttons (right side) -->
<div class="rel-row-actions">
{% if node.relation_id %}
<button onclick="deleteRelation({{ node.relation_id }})" class="btn-rel-action" title="Fjern relation" style="color:#dc3545;">
<i class="bi bi-x-lg"></i>
</button>
{% endif %}
<button class="btn-rel-action" title="Tags" onclick="openRelTagPopover({{ node.case.id }})">
<i class="bi bi-tag"></i>
</button>
<button class="btn-rel-action" title="Quick action" onclick="openRelQaMenu({{ node.case.id }}, '{{ node.case.titel | e }}', this)">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div><!-- /top row -->
<!-- Tag pills row (loaded async) -->
<div class="rel-tag-row" id="rel-tags-{{ node.case.id }}"></div>
</div>
</div>
{% if node.children %}
<div class="relation-children">
{{ render_tree(node.children) }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{# Only show tree when there is more than the lone current case #}
{% 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="relation-tree-container">
{{ render_tree(relation_tree) }}
</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" data-module="files" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📁 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">
<a id="previewDownloadBtn" href="#" class="btn btn-sm btn-outline-primary" download>
<i class="bi bi-download me-1"></i> Download
</a>
<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">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">🔗 Relateret til - Faglig kobling uden direkte afhængighed</option>
<option value="Afledt af">↪ Afledt af - Denne sag er opstået på baggrund af den anden</option>
<option value="Årsag til">➡ Årsag til - Denne sag er årsagen til den anden</option>
<option value="Blokkerer">⛔ Blokkerer - Denne sag stopper fremdrift i den anden</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>Relateret til</strong>: Bruges når sager hænger sammen, men ingen af dem afhænger direkte af den anden.</div>
<div><strong>Afledt af</strong>: Bruges når denne sag er afledt af et tidligere problem/arbejde.</div>
<div><strong>Årsag til</strong>: Bruges når denne sag skaber behovet for den anden.</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">
<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">Udsat til sagstatus</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>
<select class="form-select form-select-sm mt-2" id="deferredStatusSelect">
<option value="">Vælg status</option>
{% for st in status_options %}
<option value="{{ st }}" {% if case.deferred_until_status == st %}selected{% endif %}>
{{ st }}
</option>
{% endfor %}
</select>
</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>
<!-- 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">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<script>
const caseId = {{ case.id }};
const wikiCustomerId = {{ customer.id if customer else 'null' }};
const wikiDefaultTag = "guide";
let contactSearchTimeout;
let customerSearchTimeout;
let relationSearchTimeout;
let wikiSearchTimeout;
let selectedRelationCaseId = null;
const caseTypeKey = "{{ (case.template_key or case.type or 'ticket')|lower }}";
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',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
'solution': 'Løsning',
'sales': 'Varekøb & salg',
'subscription': 'Abonnement',
'reminders': 'Påmindelser',
'calendar': 'Kalender'
};
let caseTypeModuleDefaults = {};
// Modal instances
let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance;
let currentContactInfo = null;
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// 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'));
// 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' });
});
// Render Global Tags
if (window.renderEntityTags) {
window.renderEntityTags('case', {{ case.id }}, 'case-tags');
}
Promise.all([loadModulePrefs(), loadCaseTypeModuleDefaultsSetting()]).then(() => applyViewFromTags());
// Set default context for keyboard shortcuts (Option+Shift+T)
if (window.setTagPickerContext) {
window.setTagPickerContext('case', {{ case.id }}, () => window.renderEntityTags('case', {{ case.id }}, 'case-tags'));
}
// Load Hardware & Locations
loadCaseHardware();
loadCaseLocations();
loadCaseWiki();
loadTodoSteps();
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);
}
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 === '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() {
customerSearchModal.show();
setTimeout(() => document.getElementById('customerSearch').focus(), 300);
}
function showRelationModal() {
relationModal.show();
updateRelationTypeHint();
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
}
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 openContactRoleFromInfo() {
if (!currentContactInfo) return;
contactInfoModal.hide();
openContactRoleModal(
currentContactInfo.id,
currentContactInfo.name,
currentContactInfo.role || 'Kontakt',
currentContactInfo.isPrimary
);
}
function showCreateRelatedModal() {
createRelatedCaseModalInstance.show();
updateNewCaseRelationTypeHint();
}
function relationTypeMeaning(type) {
const map = {
'Relateret til': {
icon: '🔗',
text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
},
'Afledt af': {
icon: '↪',
text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
},
'Årsag til': {
icon: '➡',
text: '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 afledt af den nye sag.';
return;
}
if (selected === 'Årsag til') {
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som årsag til den nye 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.';
}
async function createRelatedCase() {
const title = document.getElementById('newCaseTitle').value;
const relationType = document.getElementById('newCaseRelationType').value;
const description = document.getElementById('newCaseDescription').value;
if (!title) {
alert('Titel er påkrævet');
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: {{ case.customer_id }},
status: 'åben'
})
});
if (!caseResponse.ok) throw new Error('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) throw new Error('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="addCustomer(${caseId}, ${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<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 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, '&quot;').replace(/'/g, '&#39;');
const safeCustomer = customerName.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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`);
const hardware = await res.json();
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>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-delete" onclick="unlinkHardware(${h.id})" title="Slet">
</button>
</div>
`).join('')}
`;
setModuleContentState('hardware', true);
} catch (e) {
console.error("Error loading hardware:", e);
document.getElementById('hardware-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</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`);
const locations = await res.json();
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);
document.getElementById('locations-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</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);
}
}
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 renderTodoSteps(steps) {
const list = document.getElementById('todo-steps-list');
if (!list) return;
const escapeAttr = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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 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 statusBadge = step.is_done
? '<span class="badge bg-success">Færdig</span>'
: '<span class="badge bg-warning text-dark">Åben</span>';
const toggleLabel = step.is_done ? 'Genåbn' : 'Færdig';
const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success';
const tooltipText = [
`Oprettet af: ${createdBy}`,
`Oprettet: ${createdLabel}`,
`Forfald: ${dueLabel}`,
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">
<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);
}
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" 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="mb-0"><i class="bi bi-clock-history me-2"></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-3 col-lg-4" id="case-right-column">
<div class="right-modules-grid">
<div class="card d-flex flex-column h-100 right-module-card" data-module="hardware" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">💻 Hardware</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('hardware')">
<i class="bi bi-link-45deg"></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="hardware-list">
<div class="p-3 text-center text-muted">Henter hardware...</div>
</div>
</div>
</div>
2026-03-18 09:58:31 +01:00
<div class="card h-100 d-flex flex-column right-module-card" 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="mb-0" style="color: var(--accent);">📈 Salgspipeline</h6>
<button id="pipelineEditToggle" class="btn btn-sm btn-outline-primary" onclick="togglePipelineEdit()">
<i class="bi bi-pencil"></i>
</button>
</div>
<div class="card-body">
<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>
2026-03-18 09:58:31 +01:00
<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>
2026-03-18 09:58:31 +01:00
<div class="card h-100 d-flex flex-column right-module-card" 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">
2026-03-18 09:58:31 +01:00
<h6 class="mb-0" style="color: var(--accent);">📞 Opkaldshistorik</h6>
<a href="/telefoni" class="btn btn-sm btn-outline-primary">
<i class="bi bi-telephone"></i>
</a>
</div>
2026-03-18 09:58:31 +01:00
<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 }}')">
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>
2026-03-18 09:58:31 +01:00
{% 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" data-module="todo-steps" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">✅ 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>
2026-03-18 09:58:31 +01:00
<div class="card h-100 d-flex flex-column right-module-card" data-module="wiki" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent); font-size: 0.85rem;">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">
<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="border rounded p-3 mb-3">
<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>
<label for="caseEmailBody" class="form-label form-label-sm mb-1">Besked</label>
<textarea class="form-control form-control-sm" id="caseEmailBody" rows="6" placeholder="Skriv besked..."></textarea>
</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<small id="caseEmailSendStatus" class="text-muted"></small>
<button type="button" id="caseEmailSendBtn" class="btn btn-primary btn-sm">Ny email</button>
</div>
</div>
</div>
<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-5">
<div class="border rounded h-100 d-flex flex-column">
<div class="p-2 border-bottom d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-secondary">Linkede e-mails</span>
<span class="badge bg-light text-dark border" id="linkedEmailsCount">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-7">
<div class="border rounded 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>
<!-- 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>Kilde-sag</th>
<th>Status</th>
<th class="text-end pe-4">Handlinger</th>
</tr>
</thead>
<tbody id="saleItemsPurchaseBody">
<tr>
<td colspan="9" 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>
<!-- 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> <!-- 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>
<!-- Global Comments Section (Visible on all tabs) -->
<div class="row mb-4 mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-chat-left-text me-2"></i>Kommentarer</h5>
<span class="badge bg-secondary">{{ comments|length if comments else 0 }}</span>
</div>
<div class="card-body bg-light" style="max-height: 500px; overflow-y: auto;" id="comments-container">
{% if comments %}
{% for comment in comments %}
<div class="d-flex mb-3 {{ 'justify-content-end' if comment.forfatter == 'System' else '' }}">
<div class="card {{ 'border-info' if comment.forfatter == 'System' else '' }}" style="max-width: 80%; width: fit-content;">
<div class="card-header py-1 px-3 small {{ 'bg-info text-white' if comment.forfatter == 'System' else 'bg-secondary text-white' }} d-flex justify-content-between align-items-center gap-3">
<strong>{{ comment.forfatter }}</strong>
<span>{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
</div>
<div class="card-body py-2 px-3">
{{ comment.indhold|replace('\n', '<br>')|safe }}
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
{% endif %}
</div>
<div class="card-footer bg-white">
<form id="comment-form" onsubmit="submitComment(event)">
<div class="input-group">
<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">
<i class="bi bi-send me-2"></i> Send
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
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,
forfatter: "Bruger"
})
});
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;
}
}
// Scroll to bottom of comments
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('comments-container');
if(container) {
container.scrollTop = container.scrollHeight;
}
});
</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 = [];
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) => {
if (!list.length) {
return '<tr><td colspan="9" 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>`;
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>
<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);
purchaseBody.innerHTML = renderRows(purchaseItems);
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 || '';
new bootstrap.Modal(document.getElementById('saleItemModal')).show();
}
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
};
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');
if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount);
if (priceInput) priceInput.addEventListener('input', updateSaleAmount);
loadVarekobSalg();
});
</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>
</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-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="time_date" required>
</div>
<div class="col-6">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<input type="number" class="form-control" id="time_hours_input" min="0" placeholder="tt" step="1">
<span class="input-group-text">:</span>
<input type="number" class="form-control" id="time_minutes_input" min="0" max="59" placeholder="mm" step="1">
</div>
<div class="form-text text-end" id="timeTotalCalc">Total: 0.00 timer</div>
</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 class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="time_internal">
<label class="form-check-label text-muted" for="time_internal">
Skjul for kunde (Intern registrering)
</label>
</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="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_hours_input')) {
document.getElementById('time_hours_input').value = '';
document.getElementById('time_minutes_input').value = '';
document.getElementById('timeTotalCalc').textContent = 'Total: 0.00 timer';
}
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
function updateTimeTotal() {
const h = parseInt(document.getElementById('time_hours_input').value) || 0;
const m = parseInt(document.getElementById('time_minutes_input').value) || 0;
const total = h + (m / 60);
if(document.getElementById('timeTotalCalc')) {
document.getElementById('timeTotalCalc').textContent = `Total: ${total.toFixed(2)} timer`;
}
}
// Add listeners safely
document.addEventListener('DOMContentLoaded', () => {
const hInput = document.getElementById('time_hours_input');
const mInput = document.getElementById('time_minutes_input');
if(hInput) hInput.addEventListener('input', updateTimeTotal);
if(mInput) mInput.addEventListener('input', updateTimeTotal);
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);
});
async function saveTime() {
let totalHours = 0;
// Check if we are using the new split inputs
const hInput = document.getElementById('time_hours_input');
if (hInput) {
const h = parseInt(hInput.value) || 0;
const m = parseInt(document.getElementById('time_minutes_input').value) || 0;
totalHours = h + (m/60);
} else {
// Fallback to old input if modal replacement didn't work (shouldn't happen)
totalHours = parseFloat(document.getElementById('time_hours').value) || 0;
}
if (totalHours <= 0) { alert('Indtast tid'); return; }
const billingSelect = document.getElementById('time_billing_method');
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
let prepaidCardId = null;
let fixedPriceAgreementId = null;
// Handle prepaid card selection formatting (card_123)
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
// Handle fixed-price agreement selection formatting (fpa_123)
if (billingMethod.startsWith('fpa_')) {
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'fixed_price';
}
const workTypeSelect = document.getElementById('time_work_type');
const internalCheck = document.getElementById('time_internal');
const data = {
sag_id: parseInt(document.getElementById('time_sag_id').value),
original_hours: totalHours,
description: document.getElementById('time_desc').value,
worked_date: document.getElementById('time_date').value,
work_type: workTypeSelect ? workTypeSelect.value : 'support',
billing_method: billingMethod,
is_internal: internalCheck ? internalCheck.checked : false
};
if (prepaidCardId) {
data.prepaid_card_id = prepaidCardId;
}
if (fixedPriceAgreementId) {
data.fixed_price_agreement_id = fixedPriceAgreementId;
}
try {
const res = await fetch(`/api/v1/timetracking/entries/internal`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (res.ok) {
window.location.reload();
} else {
const txt = await res.text();
alert('Fejl: ' + txt);
}
} catch(e) { console.error(e); alert('Fejl'); }
}
</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>
<!-- 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;
const caseIds = {{ case.id }};
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',
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',
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();
}
async function updateDeferredCaseAndStatus(caseId, status) {
try {
const res = await fetch(`/api/v1/sag/${caseIds}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
deferred_until_case_id: caseId ? parseInt(caseId, 10) : null,
deferred_until_status: status || 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');
const statusSelect = document.getElementById('deferredStatusSelect');
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
}
function clearDeferredCase() {
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
caseSelect.value = '';
statusSelect.value = '';
updateDeferredCaseAndStatus(null, null);
}
function saveDeferredAll() {
const input = document.getElementById('deferredUntilInput');
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
updateDeferredUntil(input.value || null);
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
}
function clearDeferredAll() {
const input = document.getElementById('deferredUntilInput');
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
input.value = '';
caseSelect.value = '';
statusSelect.value = '';
updateDeferredUntil(null);
updateDeferredCaseAndStatus(null, null);
}
function togglePipelineEdit(forceEdit = null) {
const view = document.getElementById('pipelineViewMode');
const edit = document.getElementById('pipelineEditMode');
const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit;
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();
}
}
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; }
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 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';
}
} catch (err) {
if (statusEl) {
statusEl.textContent = `❌ ${err.message}`;
}
}
}
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);
}
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'],
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
};
const defaultsByCaseType = caseTypeModuleDefaults[caseTypeKey];
const standardModules = Array.isArray(defaultsByCaseType) && defaultsByCaseType.length > 0
? defaultsByCaseType
: (viewDefaults[viewName] || []);
const standardModuleSet = new Set(standardModules);
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;
}
// Default logic (ingen brugervalg) - har den content, så vis den
if (hasContent) {
setVisibility(true);
el.classList.remove('module-empty-compact');
return;
}
// Default logic - ingen content: se på layout defaults
if (standardModuleSet.has(moduleName)) {
setVisibility(true);
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-lg-4');
leftColumn.classList.remove('col-lg-8');
leftColumn.classList.add('col-12');
} else {
rightColumn.classList.remove('d-none');
rightColumn.classList.add('col-lg-4');
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 - udvid center til fuld bredde
leftCol.classList.add('d-none');
centerCol.classList.remove('col-xl-8');
centerCol.classList.add('col-xl-12');
} else {
// Gendan 4/8 split
leftCol.classList.remove('d-none');
centerCol.classList.remove('col-xl-12');
centerCol.classList.add('col-xl-8');
}
}
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');
} 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;
} 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';
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" style="max-width: 250px;">
<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 bi-file-earmark me-1"></i> ${f.filename}
</a>
</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); }
}
// 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');
const downloadBtn = document.getElementById('previewDownloadBtn');
// Set filename and download link
fileNameEl.textContent = filename;
const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`;
downloadBtn.href = `${fileUrl}?download=true`;
downloadBtn.download = filename;
// 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 using iframe
previewContent.innerHTML = `<iframe src="${fileUrl}" class="w-100 h-100 border-0" style="min-height: 60vh;"></iframe>`;
} 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 selectedLinkedEmailId = null;
function parseEmailField(value) {
return String(value || '')
.split(/[\n,;]+/)
.map((email) => email.trim())
.filter(Boolean);
}
function escapeHtmlForInput(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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');
if (toInput && !toInput.value.trim()) {
const recipient = getDefaultCaseRecipient();
if (recipient) {
toInput.value = recipient;
}
}
if (subjectInput && !subjectInput.value.trim()) {
subjectInput.value = escapeHtmlForInput(`Sag #${caseIds}: `);
}
}
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
})
});
if (!res.ok) {
let message = 'Kunne ikke sende e-mail.';
try {
const err = await res.json();
if (err?.detail) {
message = err.detail;
}
} 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.';
loadLinkedEmails();
} catch (error) {
statusEl.className = 'text-danger';
statusEl.textContent = error?.message || 'Kunne ikke sende e-mail.';
} finally {
sendBtn.disabled = false;
}
}
function openCaseEmailTab() {
const trigger = document.getElementById('emails-tab');
if (!trigger) return;
const instance = bootstrap.Tab.getOrCreateInstance(trigger);
instance.show();
}
async function loadLinkedEmails() {
const container = document.getElementById('linked-emails-list');
if(!container) return;
try {
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`);
if(res.ok) {
linkedEmailsCache = await res.json();
applyLinkedEmailFilters();
if (selectedLinkedEmailId && linkedEmailsCache.some(e => Number(e.id) === Number(selectedLinkedEmailId))) {
await loadLinkedEmailDetail(selectedLinkedEmailId);
} else if (linkedEmailsCache.length > 0) {
await loadLinkedEmailDetail(linkedEmailsCache[0].id);
} else {
selectedLinkedEmailId = null;
renderEmailPreviewEmpty();
}
} else {
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
setModuleContentState('emails', true);
}
} catch(e) {
console.error(e);
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
setModuleContentState('emails', true);
}
}
function applyLinkedEmailFilters() {
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
const filtered = 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;
});
renderLinkedEmails(filtered);
const counter = document.getElementById('linkedEmailsCount');
if (counter) counter.textContent = String(filtered.length);
}
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>';
setModuleContentState('emails', false);
return;
}
setModuleContentState('emails', true);
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 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 ${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;
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) {
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>
`;
renderLinkedEmails(linkedEmailsCache.filter((email) => {
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
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;
}));
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 || '';
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>
<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 = true;
}
} catch (e) {
console.error(e);
panel.innerHTML = '<div class="p-3 text-danger">Fejl ved hentning af e-mail detaljer.</div>';
}
}
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();
const visible = linkedEmailsCache.filter((email) => {
const textFilter = (document.getElementById('emailFilterInput')?.value || '').trim().toLowerCase();
const attachmentFilter = document.getElementById('emailAttachmentFilter')?.value || 'all';
const readFilter = document.getElementById('emailReadFilter')?.value || 'all';
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;
});
if (!visible.some((email) => Number(email.id) === Number(selectedLinkedEmailId))) {
renderEmailPreviewEmpty();
}
});
});
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);
}
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}`);
if (res.status === 404) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
return;
}
if (!res.ok) {
throw new Error('Kunne ikke hente abonnement');
}
const subscription = await res.json();
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>
<!-- ── 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ── 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-person-check', label: 'Tildel sag', action: 'assign' },
{ 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;
};
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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 = document.querySelector('#relQaModalEl .btn-primary');
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
let success = 0; let failed = 0;
for (const file of fileInput.files) {
try {
const fd = new FormData();
fd.append('file', file);
const desc = document.getElementById('rqf_desc').value;
if (desc) fd.append('description', desc);
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
if (r.ok) success++; else failed++;
} catch { failed++; }
}
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
if (typeof showNotification === 'function') {
if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success');
else showNotification(`${success} ok, ${failed} fejlede`, 'warning');
}
};
// ── 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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 &amp; 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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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) {
// Delegate to existing email module if present
if (typeof openEmailModal === 'function') { openEmailModal(caseId); return; }
if (typeof openEmailCompose === 'function') { openEmailCompose({ sagId: caseId }); return; }
_showRelModal(
`<i class="bi bi-envelope me-2"></i>Email`,
`<p class="text-muted small">Åbn <a href="/sag/${caseId}" target="_blank">SAG-${caseId}</a> for at sende email direkte fra sagens email-modul.</p>`,
``
);
};
// ── 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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 = document.querySelector('#relQaModalEl .btn-primary');
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) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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 payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').value };
const saveBtn = document.querySelector('#relQaModalEl .btn-primary');
if (saveBtn) { saveBtn.disabled = true; }
try {
const r = await fetch('/api/v1/reminders', {
method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (r.ok) {
bootstrap.Modal.getInstance(document.getElementById('relQaModalEl'))?.hide();
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.getElementById('relQaModalEl');
if (!el) {
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.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();
// Show history and mark stale
document.getElementById('beskrivelse-history-wrap').classList.remove('d-none');
_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');
if (!rows.length) {
label.textContent = 'Historik (0)';
list.innerHTML = '<div class="list-group-item text-muted text-center py-2 small">Ingen historik endnu.</div>';
return;
}
label.textContent = `Historik (${rows.length})`;
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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(); }
});
// Show history toggle if description already exists on page load
if ((document.getElementById('beskrivelse-text').innerText || '').trim()) {
document.getElementById('beskrivelse-history-wrap').classList.remove('d-none');
}
})();
</script>
</div>
{% endblock %}