8551 lines
409 KiB
HTML
8551 lines
409 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
||
|
||
{% block title %}{{ case.titel }} - BMC Hub{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
border-radius: 999px;
|
||
background: rgba(15, 76, 117, 0.12);
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
margin-right: 0.35rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
.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">
|
||
<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">
|
||
<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">
|
||
<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">
|
||
<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">
|
||
<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">
|
||
<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">
|
||
<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('"', '"') }}"
|
||
data-title="{{ contact.title|default('', true)|replace('"', '"') }}"
|
||
data-company="{{ contact.customer_name|default('', true)|replace('"', '"') }}"
|
||
data-email="{{ contact.contact_email|default('', true)|replace('"', '"') }}"
|
||
data-phone="{{ contact.phone|default('', true)|replace('"', '"') }}"
|
||
data-mobile="{{ contact.mobile|default('', true)|replace('"', '"') }}"
|
||
data-role="{{ contact.role|default('Kontakt')|replace('"', '"') }}"
|
||
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
|
||
>
|
||
<div class="contact-name">{{ contact.contact_name }}</div>
|
||
<small>{{ contact.title or '-' }}</small>
|
||
<small>{{ contact.customer_name or '-' }}</small>
|
||
<button
|
||
class="btn btn-sm btn-delete"
|
||
onclick="event.stopPropagation(); removeContact({{ case.id }}, {{ contact.contact_id }})"
|
||
title="Slet"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<p class="text-muted text-center">Ingen kontakter</p>
|
||
{% endif %}
|
||
</div>
|
||
</div></div>
|
||
<div class="row mb-3">
|
||
<div class="col-12 mb-3">
|
||
<div class="card h-100 d-flex flex-column" 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-md-4">
|
||
<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-md-4">
|
||
<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-md-4">
|
||
<div class="summary-item h-100">
|
||
<div class="summary-label">Beløb</div>
|
||
<div class="summary-value">
|
||
{% if case.pipeline_amount is not none %}
|
||
{{ "{:,.2f}".format(case.pipeline_amount|float).replace(',', 'X').replace('.', ',').replace('X', '.') }} kr.
|
||
{% else %}
|
||
<span class="text-muted">Ikke sat</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3">
|
||
<div class="summary-item">
|
||
<div class="summary-label">Beskrivelse</div>
|
||
<div class="summary-value" style="white-space: pre-wrap;">{{ case.pipeline_description or 'Ingen beskrivelse' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="pipelineEditMode" class="d-none">
|
||
<div class="row g-3">
|
||
<div class="col-md-4">
|
||
<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-md-4">
|
||
<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-md-4">
|
||
<label class="form-label small text-muted">Beløb (kr.)</label>
|
||
<input id="pipelineAmountInput" type="number" min="0" step="0.01" class="form-control form-control-sm" value="{{ case.pipeline_amount if case.pipeline_amount is not none else '' }}">
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label small text-muted">Beskrivelse</label>
|
||
<textarea id="pipelineDescriptionInput" rows="3" class="form-control form-control-sm" placeholder="Skriv kort note for denne pipeline-entry...">{{ case.pipeline_description or '' }}</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="d-flex justify-content-end gap-2 mt-3">
|
||
<button class="btn btn-sm btn-outline-secondary" onclick="togglePipelineEdit(false)">Annuller</button>
|
||
<button class="btn btn-sm btn-primary" onclick="savePipeline()">Gem pipeline</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="row mb-3">
|
||
<div class="col-12 mb-3">
|
||
<div class="card h-100 d-flex flex-column" data-module="call-history" data-has-content="{{ 'true' if call_history else 'false' }}">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h6 class="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>
|
||
<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>
|
||
{% else %}
|
||
<div class="p-3 text-muted text-center">Ingen opkald linket til denne sag</div>
|
||
{% endif %}
|
||
</div>
|
||
</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;">▶ #{{ 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 sag‑status</label>
|
||
<select class="form-select form-select-sm" id="deferredCaseSelect">
|
||
<option value="">Vælg relateret sag</option>
|
||
{% for rc in related_case_options %}
|
||
<option value="{{ rc.id }}" {% if case.deferred_until_case_id == rc.id %}selected{% endif %}>
|
||
#{{ rc.id }} – {{ rc.titel }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
<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 }}";
|
||
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);
|
||
}
|
||
|
||
// 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, '"').replace(/'/g, ''');
|
||
const safeCustomer = customerName.replace(/"/g, '"').replace(/'/g, ''');
|
||
|
||
html += `
|
||
<div class="list-group-item list-group-item-action relation-search-item"
|
||
style="cursor: pointer; padding: 0.75rem 1rem;"
|
||
onclick="selectRelationCase(${c.id}, '${safeTitle}', '${safeCustomer}', '${status}');"
|
||
data-case-id="${c.id}">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div style="flex: 1;">
|
||
<div class="d-flex align-items-center gap-2 mb-1">
|
||
<span class="badge bg-primary" style="font-size: 0.75rem;">#${c.id}</span>
|
||
<strong style="font-size: 0.95rem;">${escapeHtml(c.titel)}</strong>
|
||
</div>
|
||
${c.customer_name ? `
|
||
<div class="small text-muted mb-1">
|
||
<i class="bi bi-building me-1"></i>${escapeHtml(c.customer_name)}
|
||
</div>
|
||
` : ''}
|
||
${beskrivelse ? `
|
||
<div class="small text-muted" style="font-size: 0.8rem;">${escapeHtml(beskrivelse)}</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="text-end" style="min-width: 100px;">
|
||
<div class="small text-muted">${createdDate}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
});
|
||
|
||
html += '</div>';
|
||
resultsDiv.innerHTML = html;
|
||
resultsDiv.style.display = 'block';
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function selectRelationCase(caseIdValue, caseTitel, customerName, status) {
|
||
selectedRelationCaseId = caseIdValue;
|
||
|
||
// Update preview
|
||
const previewDiv = document.getElementById('selectedCasePreview');
|
||
const titleDiv = document.getElementById('selectedCaseTitle');
|
||
|
||
titleDiv.innerHTML = `
|
||
<div class="d-flex align-items-center gap-2 mb-1">
|
||
<span class="badge bg-primary">#${caseIdValue}</span>
|
||
<strong>${escapeHtml(caseTitel)}</strong>
|
||
<span class="status-badge status-${status}">${status}</span>
|
||
</div>
|
||
${customerName ? `<div class="small"><i class="bi bi-building me-1"></i>${escapeHtml(customerName)}</div>` : ''}
|
||
`;
|
||
|
||
previewDiv.style.display = 'block';
|
||
document.getElementById('relationSearchResults').innerHTML = '';
|
||
document.getElementById('relationSearchResults').style.display = 'none';
|
||
document.getElementById('relationCaseSearch').value = '';
|
||
|
||
// Enable add button
|
||
updateAddRelationButton();
|
||
}
|
||
|
||
function clearSelectedRelationCase() {
|
||
selectedRelationCaseId = null;
|
||
document.getElementById('selectedCasePreview').style.display = 'none';
|
||
document.getElementById('relationCaseSearch').value = '';
|
||
document.getElementById('relationCaseSearch').focus();
|
||
updateAddRelationButton();
|
||
}
|
||
|
||
function updateAddRelationButton() {
|
||
const btn = document.getElementById('addRelationBtn');
|
||
const relationType = document.getElementById('relationTypeSelect').value;
|
||
btn.disabled = !selectedRelationCaseId || !relationType;
|
||
}
|
||
|
||
async function addRelation() {
|
||
const relationType = document.getElementById('relationTypeSelect').value;
|
||
const btn = document.getElementById('addRelationBtn');
|
||
|
||
if (!selectedRelationCaseId) {
|
||
alert('Vælg en sag først');
|
||
return;
|
||
}
|
||
|
||
if (!relationType) {
|
||
alert('Vælg en relationstype');
|
||
return;
|
||
}
|
||
|
||
// Disable button during request
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Tilføjer...';
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/sag/${caseId}/relationer`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
målsag_id: selectedRelationCaseId,
|
||
relationstype: relationType
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
selectedRelationCaseId = null;
|
||
relationModal.hide();
|
||
window.location.reload();
|
||
} else {
|
||
const error = await response.json();
|
||
alert(`Fejl: ${error.detail}`);
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
|
||
}
|
||
} catch (err) {
|
||
alert('Fejl ved tilføjelse af relation: ' + err.message);
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
|
||
}
|
||
}
|
||
|
||
async function deleteRelation(relationId) {
|
||
if (confirm('Fjern denne relation?')) {
|
||
const response = await fetch(`/api/v1/sag/${caseId}/relationer/${relationId}`, {method: 'DELETE'});
|
||
if (response.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
alert('Fejl ved fjernelse af relation');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============ Hardware Handling ============
|
||
async function loadCaseHardware() {
|
||
try {
|
||
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`);
|
||
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, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
|
||
if (!steps || steps.length === 0) {
|
||
list.innerHTML = '<div class="p-3 text-center text-muted">Ingen opgaver endnu</div>';
|
||
setModuleContentState('todo-steps', false);
|
||
return;
|
||
}
|
||
|
||
const openSteps = steps.filter(step => !step.is_done);
|
||
const doneSteps = steps.filter(step => step.is_done);
|
||
|
||
const 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>
|
||
<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>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<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 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>
|
||
|
||
</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">
|
||
<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' }}">
|
||
<!-- 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">
|
||
<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">
|
||
<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">
|
||
<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;
|
||
|
||
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;
|
||
}
|
||
|
||
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 = getReminderUserId();
|
||
|
||
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/${caseIds}/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 = getReminderUserId();
|
||
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/${caseIds}/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/${caseIds}/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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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();
|
||
tabBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}, 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
|
||
// ── load global entity tags into rel-tag-row divs (using global tag system) ──
|
||
async function loadAllRelationTags() {
|
||
const rows = Array.from(document.querySelectorAll('.rel-tag-row'));
|
||
if (!rows.length) return;
|
||
// Wait briefly for tag-picker.js to initialize
|
||
const renderFn = () => window.renderEntityTags;
|
||
await new Promise(res => { const t = setInterval(() => { if (renderFn()) { clearInterval(t); res(); } }, 50); setTimeout(() => { clearInterval(t); res(); }, 2000); });
|
||
await Promise.all(rows.map(async el => {
|
||
const caseId = parseInt(el.id.replace('rel-tags-', ''));
|
||
if (isNaN(caseId) || !window.renderEntityTags) return;
|
||
await window.renderEntityTags('case', caseId, el.id);
|
||
}));
|
||
}
|
||
|
||
// ── tag button → opens global tag picker ──────────────────────────
|
||
window.openRelTagPopover = function(caseId) {
|
||
if (!window.showTagPicker) return;
|
||
window.showTagPicker('case', caseId, () => {
|
||
if (window.renderEntityTags) window.renderEntityTags('case', caseId, 'rel-tags-' + caseId);
|
||
});
|
||
};
|
||
|
||
// ── quick action menu ─────────────────────────────────────────────
|
||
const QA_ITEMS = [
|
||
{ icon: 'bi-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 & 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
const trunc = (s, n) => s && s.length > n ? s.substring(0, n) + '…' : (s || '');
|
||
list.innerHTML = rows.map(h => {
|
||
const d = new Date(h.changed_at);
|
||
const when = d.toLocaleDateString('da-DK', {day:'2-digit',month:'2-digit',year:'numeric'})
|
||
+ ' ' + d.toLocaleTimeString('da-DK', {hour:'2-digit',minute:'2-digit'});
|
||
const who = esc(h.changed_by_name || 'Ukendt');
|
||
const before = h.beskrivelse_before ? esc(trunc(h.beskrivelse_before, 150)) : '<em class="text-muted">tom</em>';
|
||
const after = h.beskrivelse_after ? esc(trunc(h.beskrivelse_after, 150)) : '<em class="text-muted">tom</em>';
|
||
return `<div class="list-group-item px-3 py-2">
|
||
<div class="d-flex justify-content-between mb-1">
|
||
<span class="fw-semibold small">${who}</span>
|
||
<span class="text-muted small">${when}</span>
|
||
</div>
|
||
<div class="d-flex gap-3" style="font-size:.85rem">
|
||
<div style="flex:1"><span class="badge text-bg-danger me-1" style="font-size:.7rem">Før</span>${before}</div>
|
||
<div style="flex:1"><span class="badge text-bg-success me-1" style="font-size:.7rem">Efter</span>${after}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
list.innerHTML = '<div class="list-group-item text-muted text-center py-2 small">Kunne ikke indlæse historik.</div>';
|
||
}
|
||
};
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', function (e) {
|
||
const editor = document.getElementById('beskrivelse-editor');
|
||
if (!editor || editor.classList.contains('d-none')) return;
|
||
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); saveBeskrivelsEdit(); }
|
||
if (e.key === 'Escape') { e.preventDefault(); cancelBeskrivelsEdit(); }
|
||
});
|
||
|
||
// 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 %}
|