2026-02-01 11:58:44 +01:00
{% 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;
}
2026-03-05 08:41:59 +01:00
/* ═══════════════ 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);
}
2026-02-06 10:47:14 +01:00
.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);
}
2026-02-15 11:12:58 +01:00
.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;
}
2026-02-06 10:47:14 +01:00
.relation-tree {
list-style: none;
margin: 0;
padding-left: 0;
}
2026-02-15 11:12:58 +01:00
/* Sub-trees need indentation */
.relation-children .relation-tree {
margin-left: 8px; /* Indent children */
}
2026-02-06 10:47:14 +01:00
.relation-node {
position: relative;
2026-02-15 11:12:58 +01:00
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);
2026-02-06 10:47:14 +01:00
}
2026-02-15 11:12:58 +01:00
/* Horizontal Line (Connector) */
2026-02-06 10:47:14 +01:00
.relation-node:before {
content: "";
position: absolute;
2026-02-15 11:12:58 +01:00
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);
2026-02-06 10:47:14 +01:00
}
2026-02-15 11:12:58 +01:00
/* 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: '';
2026-02-06 10:47:14 +01:00
position: absolute;
2026-02-15 11:12:58 +01:00
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;
2026-02-06 10:47:14 +01:00
height: 1px;
2026-02-15 11:12:58 +01:00
border-top: 1px solid rgba(15, 76, 117, 0.25);
2026-02-06 10:47:14 +01:00
}
2026-02-15 11:12:58 +01:00
/* 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 */
2026-02-06 10:47:14 +01:00
}
.relation-children {
2026-02-15 11:12:58 +01:00
margin-left: 24px; /* Indent for next level */
2026-02-06 10:47:14 +01:00
}
2026-02-15 11:12:58 +01:00
2026-02-06 10:47:14 +01:00
.relation-node-card {
background: rgba(15, 76, 117, 0.03);
border: 1px solid rgba(15, 76, 117, 0.12);
2026-02-15 11:12:58 +01:00
transition: background 0.2s;
}
.relation-node-card:hover {
background: rgba(15, 76, 117, 0.08);
2026-02-06 10:47:14 +01:00
}
2026-03-05 08:41:59 +01:00
/* ── 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);
}
2026-02-06 10:47:14 +01:00
.relation-type-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
2026-02-15 11:12:58 +01:00
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;
}
2026-02-06 10:47:14 +01:00
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;
}
2026-02-01 11:58:44 +01:00
.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 %}
2026-03-05 08:41:59 +01:00
< div class = "container-fluid" style = "margin-top: 2rem; margin-bottom: 2rem;" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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 >
2026-02-06 10:47:14 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< / div >
2026-02-01 11:58:44 +01:00
2026-03-05 08:41:59 +01:00
{% 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) %}
2026-02-15 11:12:58 +01:00
2026-03-05 08:41:59 +01:00
<!-- ═══════════════ PREMIUM CASE HEADER ═══════════════ -->
< div class = "case-hero mb-4" >
2026-02-15 11:12:58 +01:00
2026-03-05 08:41:59 +01:00
<!-- ── Row 1: Identity stripe ── -->
< div class = "case-hero-identity" >
2026-02-15 11:12:58 +01:00
2026-03-05 08:41:59 +01:00
<!-- 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 >
2026-02-15 11:12:58 +01:00
2026-03-05 08:41:59 +01:00
< span class = "case-status-chip {{ 'open' if case.status == 'åben' else 'closed' }}" >
< span class = "case-status-dot" > < / span > {{ case.status|capitalize }}
< / span >
< / div >
2026-02-17 08:29:05 +01:00
2026-03-05 08:41:59 +01:00
<!-- 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 >
2026-02-15 11:12:58 +01:00
2026-03-05 08:41:59 +01:00
<!-- ── 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 >
2026-02-15 11:12:58 +01:00
< / button >
2026-03-05 08:41:59 +01:00
< 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 >
2026-02-01 11:58:44 +01:00
< / div >
< / div >
2026-03-05 08:41:59 +01:00
< 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 >
2026-02-17 08:29:05 +01:00
{% 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 >
2026-03-05 08:41:59 +01:00
< select id = "assignmentGroupSelect" class = "case-inline-select" onchange = "saveAssignment()" >
< option value = "" > Ingen gruppe< / option >
2026-02-17 08:29:05 +01:00
{% 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 >
2026-03-05 08:41:59 +01:00
< / 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 >
2026-02-17 08:29:05 +01:00
< / div >
< / div >
2026-03-05 08:41:59 +01:00
2026-02-17 08:29:05 +01:00
< / div >
< / div >
2026-03-05 08:41:59 +01:00
<!-- ═══════════════ END CASE HEADER ═══════════════ -->
2026-02-17 08:29:05 +01:00
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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" >
2026-02-06 10:47:14 +01:00
< button class = "nav-link" id = "solution-tab" data-bs-toggle = "tab" data-bs-target = "#solution" type = "button" role = "tab" data-module-tab = "solution" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2026-03-03 14:33:11 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< li class = "nav-item" role = "presentation" >
2026-02-06 10:47:14 +01:00
< 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
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / button >
< / li >
2026-02-08 12:42:19 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< li class = "nav-item" role = "presentation" >
2026-02-06 10:47:14 +01:00
< button class = "nav-link" id = "reminders-tab" data-bs-toggle = "tab" data-bs-target = "#reminders" type = "button" role = "tab" data-module-tab = "reminders" >
2026-02-15 11:12:58 +01:00
< i class = "bi bi-bell me-2" > < / i > Påmindelser
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / 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" >
2026-02-06 10:47:14 +01:00
< div class = "row g-4" >
2026-03-05 08:41:59 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
< / div >
2026-03-05 08:41:59 +01:00
< / 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 >
2026-02-01 11:58:44 +01:00
< / div >
2026-03-05 08:41:59 +01:00
{% 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 %}
2026-02-01 11:58:44 +01:00
< / div >
2026-03-05 08:41:59 +01:00
< / 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 >
2026-02-15 11:12:58 +01:00
< / div >
2026-03-05 08:41:59 +01:00
{% endfor %}
{% else %}
< p class = "text-muted text-center" > Ingen kontakter< / p >
{% endif %}
2026-02-15 11:12:58 +01:00
< / div >
2026-03-05 08:41:59 +01:00
< / div > < / div >
< div class = "row mb-3" >
2026-02-15 11:12:58 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
{% else %}
2026-02-15 11:12:58 +01:00
< span class = "text-muted" > Ikke sat< / span >
2026-02-06 10:47:14 +01:00
{% endif %}
2026-02-15 11:12:58 +01:00
< / 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.
2026-02-06 10:47:14 +01:00
{% else %}
2026-02-15 11:12:58 +01:00
< span class = "text-muted" > Ikke sat< / span >
2026-02-06 10:47:14 +01:00
{% endif %}
2026-02-15 11:12:58 +01:00
< / div >
2026-02-06 10:47:14 +01:00
< / div >
< / div >
2026-02-01 11:58:44 +01:00
< / div >
2026-02-15 11:12:58 +01:00
< 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 >
2026-02-01 11:58:44 +01:00
< / div >
2026-02-15 11:12:58 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
< / div >
2026-02-01 11:58:44 +01:00
< / div >
< / div >
< / div >
< / div >
2026-03-05 08:41:59 +01:00
< 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 })
});
2026-02-01 11:58:44 +01:00
2026-03-05 08:41:59 +01:00
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" >
2026-02-06 10:47:14 +01:00
< 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' }}" >
2026-02-01 11:58:44 +01:00
< div class = "card-header d-flex justify-content-between align-items-center" >
2026-02-20 07:10:06 +01:00
< 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"
2026-02-17 12:49:11 +01:00
data-bs-html="true"
2026-02-20 07:10:06 +01:00
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 >
2026-02-06 10:47:14 +01:00
< div class = "d-flex gap-2" >
< button class = "btn btn-sm btn-outline-primary" onclick = "showRelationModal()" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< i class = "bi bi-link-45deg" > < / i >
< / button >
2026-02-06 10:47:14 +01:00
< button class = "btn btn-sm btn-outline-primary" onclick = "showCreateRelatedModal()" >
< i class = "bi bi-plus-lg" > < / i >
< / button >
2026-02-01 11:58:44 +01:00
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< div class = "card-body flex-grow-1 overflow-auto" style = "max-height: 300px;" >
{% macro render_tree(nodes) %}
2026-02-06 10:47:14 +01:00
< ul class = "relation-tree" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
{% for node in nodes %}
2026-02-06 10:47:14 +01:00
< li class = "relation-node" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< div class = "d-flex align-items-center py-1" >
2026-03-05 08:41:59 +01:00
< 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;" >
2026-02-15 11:12:58 +01:00
<!-- Relation Type Icon/Badge -->
2026-02-06 10:47:14 +01:00
{% if node.relation_type %}
2026-02-15 11:12:58 +01:00
{% set rel_icon = 'bi-link-45deg' %}
{% set rel_color = 'text-muted' %}
2026-02-17 08:29:05 +01:00
{% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
2026-02-15 11:12:58 +01:00
{% if node.relation_type == 'Afledt af' %}
{% set rel_icon = 'bi-arrow-return-right' %}
{% set rel_color = 'text-info' %}
2026-02-17 08:29:05 +01:00
{% 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' %}
2026-03-05 08:41:59 +01:00
{% set rel_help = 'Denne sag er årsagen til en anden sag' %}
2026-02-15 11:12:58 +01:00
{% elif node.relation_type == 'Blokkerer' %}
{% set rel_icon = 'bi-slash-circle' %}
{% set rel_color = 'text-danger' %}
2026-02-17 08:29:05 +01:00
{% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
2026-02-15 11:12:58 +01:00
{% endif %}
2026-02-17 08:29:05 +01:00
< span class = "relation-type-badge {{ rel_color }}" title = "{{ node.relation_type }}: {{ rel_help }}" >
2026-02-15 11:12:58 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
{% endif %}
2026-02-15 11:12:58 +01:00
<!-- Case Link -->
2026-03-05 08:41:59 +01:00
{% 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;" >
2026-02-15 11:12:58 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / a >
2026-03-05 08:41:59 +01:00
{% endif %}
2026-02-15 11:12:58 +01:00
<!-- Status Dot -->
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< span class = "status-dot status-{{ node.case.status }} ms-2" title = "{{ node.case.status }}" > < / span >
2026-02-15 11:12:58 +01:00
<!-- 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 %}
2026-03-05 08:41:59 +01:00
<!-- 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;" >
2026-02-15 11:12:58 +01:00
< i class = "bi bi-x-lg" > < / i >
< / button >
2026-03-05 08:41:59 +01:00
{% 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 >
2026-02-15 11:12:58 +01:00
< / div >
2026-03-05 08:41:59 +01:00
< / div > <!-- /top row -->
<!-- Tag pills row (loaded async) -->
< div class = "rel-tag-row" id = "rel-tags-{{ node.case.id }}" > < / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< / div >
{% if node.children %}
2026-02-06 10:47:14 +01:00
< div class = "relation-children" >
{{ render_tree(node.children) }}
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
{% endif %}
< / li >
{% endfor %}
< / ul >
{% endmacro %}
2026-03-05 08:41:59 +01:00
{# 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 %}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< div class = "relation-tree-container" >
2026-02-06 10:47:14 +01:00
{{ render_tree(relation_tree) }}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
2026-02-01 11:58:44 +01:00
{% else %}
2026-03-05 08:41:59 +01:00
< p class = "text-muted text-center pt-3" > < i class = "bi bi-diagram-3 me-1" > < / i > Ingen relaterede sager< / p >
2026-02-14 02:26:29 +01:00
{% endif %}
< / div >
< / div >
< / div >
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- ROW 3: Files + Linked Emails -->
< div class = "row mb-3" >
<!-- Files -->
2026-03-05 08:41:59 +01:00
< div class = "col-12 mb-3" >
2026-02-14 02:26:29 +01:00
< div class = "card h-100" data-module = "files" data-has-content = "unknown" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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()" >
2026-02-14 02:26:29 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< div class = "list-group list-group-flush flex-grow-1 overflow-auto" id = "files-list" style = "max-height: 300px;" >
2026-02-14 02:26:29 +01:00
< div class = "p-3 text-center text-muted" > Ingen filer fundet...< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< / div >
2026-02-14 02:26:29 +01:00
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< / div >
2026-02-06 10:47:14 +01:00
<!-- 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 >
2026-02-01 11:58:44 +01:00
<!-- 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 >
2026-02-17 08:29:05 +01:00
< select id = "relationTypeSelect" class = "form-control form-control-lg" onchange = "updateAddRelationButton(); updateRelationTypeHint();" >
2026-02-01 11:58:44 +01:00
< option value = "" > Vælg hvordan sagerne er relateret...< / option >
2026-02-17 08:29:05 +01:00
< 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 >
2026-02-01 11:58:44 +01:00
< / select >
< / div >
2026-02-17 08:29:05 +01:00
< 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 >
2026-02-01 11:58:44 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
<!-- Contact Info Modal -->
< div class = "modal fade" id = "contactInfoModal" tabindex = "-1" aria-hidden = "true" >
< div class = "modal-dialog modal-dialog-centered modal-sm" >
2026-02-01 11:58:44 +01:00
< div class = "modal-content" >
< div class = "modal-header" >
2026-02-06 10:47:14 +01:00
< h5 class = "modal-title" id = "contactInfoName" > Kontakt< / h5 >
2026-02-01 11:58:44 +01:00
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
2026-02-06 10:47:14 +01:00
< 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" >
2026-02-15 11:12:58 +01:00
< div class = "text-muted small" > E-mail< / div >
2026-02-06 10:47:14 +01:00
< 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 >
2026-02-01 11:58:44 +01:00
< / div >
< / div >
2026-02-06 10:47:14 +01:00
< 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 >
2026-02-17 08:29:05 +01:00
<!-- 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 >
2026-02-06 10:47:14 +01:00
<!-- 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 >
2026-02-01 11:58:44 +01:00
< 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 }};
2026-02-10 14:40:38 +01:00
const wikiCustomerId = {{ customer.id if customer else 'null' }};
const wikiDefaultTag = "guide";
2026-02-01 11:58:44 +01:00
let contactSearchTimeout;
let customerSearchTimeout;
let relationSearchTimeout;
2026-02-10 14:40:38 +01:00
let wikiSearchTimeout;
2026-02-01 11:58:44 +01:00
let selectedRelationCaseId = null;
2026-02-15 11:12:58 +01:00
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 = {};
2026-02-01 11:58:44 +01:00
// Modal instances
2026-02-06 10:47:14 +01:00
let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance;
let currentContactInfo = null;
2026-02-01 11:58:44 +01:00
// 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'));
2026-02-06 10:47:14 +01:00
contactInfoModal = new bootstrap.Modal(document.getElementById('contactInfoModal'));
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
createRelatedCaseModalInstance = new bootstrap.Modal(document.getElementById('createRelatedCaseModal'));
2026-02-01 11:58:44 +01:00
// Setup search handlers
setupContactSearch();
setupCustomerSearch();
setupRelationSearch();
2026-02-17 08:29:05 +01:00
updateRelationTypeHint();
updateNewCaseRelationTypeHint();
2026-02-20 07:10:06 +01:00
// Initialize all tooltips on the page
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
bootstrap.Tooltip.getOrCreateInstance(el, { html: true, container: 'body' });
});
2026-02-01 11:58:44 +01:00
// Render Global Tags
if (window.renderEntityTags) {
window.renderEntityTags('case', {{ case.id }}, 'case-tags');
}
2026-02-15 11:12:58 +01:00
Promise.all([loadModulePrefs(), loadCaseTypeModuleDefaultsSetting()]).then(() => applyViewFromTags());
2026-02-06 10:47:14 +01:00
2026-02-01 11:58:44 +01:00
// 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();
2026-02-10 14:40:38 +01:00
loadCaseWiki();
2026-02-14 02:26:29 +01:00
loadTodoSteps();
2026-02-10 14:40:38 +01:00
const wikiSearchInput = document.getElementById('wikiSearchInput');
if (wikiSearchInput) {
wikiSearchInput.addEventListener('input', () => {
clearTimeout(wikiSearchTimeout);
wikiSearchTimeout = setTimeout(() => {
loadCaseWiki(wikiSearchInput.value || '');
}, 300);
});
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
2026-02-14 02:26:29 +01:00
const todoForm = document.getElementById('todoStepForm');
if (todoForm) {
todoForm.addEventListener('submit', createTodoStep);
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
// Focus on title when create modal opens
const createModalEl = document.getElementById('createRelatedCaseModal');
if (createModalEl) {
createModalEl.addEventListener('shown.bs.modal', function () {
document.getElementById('newCaseTitle').focus();
});
}
2026-02-01 11:58:44 +01:00
});
// Show modal functions
function showContactSearch() {
contactSearchModal.show();
setTimeout(() => document.getElementById('contactSearch').focus(), 300);
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
function showCustomerSearch() {
customerSearchModal.show();
setTimeout(() => document.getElementById('customerSearch').focus(), 300);
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
function showRelationModal() {
relationModal.show();
2026-02-17 08:29:05 +01:00
updateRelationTypeHint();
2026-02-01 11:58:44 +01:00
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
2026-02-06 10:47:14 +01:00
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 || '-';
2026-02-14 02:26:29 +01:00
document.getElementById('contactInfoPhone').innerHTML = renderCasePhone(currentContactInfo.phone);
document.getElementById('contactInfoMobile').innerHTML = renderCaseMobile(currentContactInfo.mobile, currentContactInfo.name);
2026-02-06 10:47:14 +01:00
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();
}
2026-02-14 02:26:29 +01:00
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 >
`;
}
2026-02-06 10:47:14 +01:00
function openContactRoleFromInfo() {
if (!currentContactInfo) return;
contactInfoModal.hide();
openContactRoleModal(
currentContactInfo.id,
currentContactInfo.name,
currentContactInfo.role || 'Kontakt',
currentContactInfo.isPrimary
);
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
function showCreateRelatedModal() {
createRelatedCaseModalInstance.show();
2026-02-17 08:29:05 +01:00
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.';
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
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);
}
}
2026-02-01 11:58:44 +01:00
function confirmDeleteCase() {
if(confirm('Slet denne sag?')) {
fetch('/api/v1/sag/{{ case.id }}', {method: 'DELETE'})
.then(() => window.location='/sag');
}
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
// 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);
});
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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);
}
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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');
}
}
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
// 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);
});
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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);
}
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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');
}
}
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
// Relation Search - Enhanced version
let currentFocusIndex = -1;
let searchResults = [];
2026-02-01 00:29:57 +01:00
2026-02-01 11:58:44 +01:00
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();
}
}
});
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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');
}
});
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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';
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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();
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
function clearSelectedRelationCase() {
selectedRelationCaseId = null;
document.getElementById('selectedCasePreview').style.display = 'none';
document.getElementById('relationCaseSearch').value = '';
document.getElementById('relationCaseSearch').focus();
updateAddRelationButton();
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
function updateAddRelationButton() {
const btn = document.getElementById('addRelationBtn');
const relationType = document.getElementById('relationTypeSelect').value;
btn.disabled = !selectedRelationCaseId || !relationType;
2026-02-01 00:29:57 +01:00
}
2026-02-01 11:58:44 +01:00
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';
}
2026-02-01 00:29:57 +01:00
}
2026-01-31 23:16:24 +01:00
2026-02-01 11:58:44 +01:00
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');
}
}
}
2026-01-29 23:07:33 +01:00
2026-02-01 11:58:44 +01:00
// ============ 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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('hardware', false);
2026-02-01 11:58:44 +01:00
return;
}
2026-02-06 10:47:14 +01:00
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" >
2026-02-01 11:58:44 +01:00
${h.brand} ${h.model}
< / a >
< / div >
2026-02-06 10:47:14 +01:00
< small > ${h.serial_number || '-'}< / small >
< button class = "btn btn-sm btn-delete" onclick = "unlinkHardware(${h.id})" title = "Slet" >
✕
< / button >
2026-01-29 23:07:33 +01:00
< / div >
2026-02-06 10:47:14 +01:00
`).join('')}
`;
2026-02-14 02:26:29 +01:00
setModuleContentState('hardware', true);
2026-02-01 11:58:44 +01:00
} 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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('hardware', true);
2026-02-01 11:58:44 +01:00
}
}
2026-02-01 00:29:57 +01:00
2026-02-01 11:58:44 +01:00
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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('locations', false);
2026-02-01 11:58:44 +01:00
return;
}
2026-02-06 10:47:14 +01:00
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" >
2026-02-01 11:58:44 +01:00
< i class = "bi bi-geo-alt me-1 text-secondary" > < / i >
${l.name}
< / div >
2026-02-06 10:47:14 +01:00
< small > ${l.location_type || '-'}< / small >
2026-02-17 08:29:05 +01:00
< button class = "btn btn-sm btn-delete" onclick = "unlinkLocation(${l.relation_id || l.id})" title = "Slet" >
2026-02-06 10:47:14 +01:00
✕
< / button >
2026-01-29 23:07:33 +01:00
< / div >
2026-02-06 10:47:14 +01:00
`).join('')}
`;
2026-02-14 02:26:29 +01:00
setModuleContentState('locations', true);
2026-02-01 11:58:44 +01:00
} 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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('locations', true);
2026-02-01 11:58:44 +01:00
}
}
2026-01-29 23:07:33 +01:00
2026-02-10 14:40:38 +01:00
// ============ 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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('wiki', false);
2026-02-10 14:40:38 +01:00
return;
}
2026-02-15 11:12:58 +01:00
container.innerHTML = '< div class = "p-3 text-center text-muted small" > Henter wiki...< / div > ';
2026-02-10 14:40:38 +01:00
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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('wiki', true);
2026-02-10 14:40:38 +01:00
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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('wiki', false);
2026-02-10 14:40:38 +01:00
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('');
2026-02-14 02:26:29 +01:00
setModuleContentState('wiki', true);
2026-02-10 14:40:38 +01:00
} catch (e) {
console.error('Error loading Wiki:', e);
container.innerHTML = '< div class = "p-3 text-center text-danger small" > Fejl ved hentning< / div > ';
2026-02-14 02:26:29 +01:00
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;
2026-02-15 11:12:58 +01:00
const escapeAttr = (value) => String(value ?? '')
.replace(/& /g, '& ')
.replace(/"/g, '" ')
.replace(/< /g, '< ')
.replace(/>/g, '> ');
2026-02-14 02:26:29 +01:00
if (!steps || steps.length === 0) {
2026-02-15 11:12:58 +01:00
list.innerHTML = '< div class = "p-3 text-center text-muted" > Ingen opgaver endnu< / div > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('todo-steps', false);
return;
}
2026-02-15 11:12:58 +01:00
const openSteps = steps.filter(step => !step.is_done);
const doneSteps = steps.filter(step => step.is_done);
const renderStep = (step) => {
2026-02-14 02:26:29 +01:00
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
2026-02-15 11:12:58 +01:00
? '< 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';
2026-02-14 02:26:29 +01:00
const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success';
2026-02-15 11:12:58 +01:00
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 > ');
2026-02-14 02:26:29 +01:00
return `
2026-02-15 11:12:58 +01:00
< 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 >
2026-02-14 02:26:29 +01:00
< / div >
2026-02-15 11:12:58 +01:00
< div class = "todo-step-right" >
2026-02-14 02:26:29 +01:00
${statusBadge}
2026-02-15 11:12:58 +01:00
< 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 >
2026-02-14 02:26:29 +01:00
< / div >
< / div >
< / div >
2026-02-15 11:12:58 +01:00
${step.description ? `< div class = "small text-muted" > ${step.description}< / div > ` : ''}
< div class = "todo-step-meta" >
< span class = "meta-pill" > Forfald: ${dueLabel}< / span >
< / div >
2026-02-14 02:26:29 +01:00
< / div >
`;
2026-02-15 11:12:58 +01:00
};
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
});
});
}
2026-02-14 02:26:29 +01:00
setModuleContentState('todo-steps', true);
}
async function loadTodoSteps() {
const list = document.getElementById('todo-steps-list');
if (!list) return;
2026-02-15 11:12:58 +01:00
list.innerHTML = '< div class = "p-3 text-center text-muted" > Henter opgaver...< / div > ';
2026-02-14 02:26:29 +01:00
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);
}
}
2026-02-15 11:12:58 +01:00
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);
}
}
2026-02-14 02:26:29 +01:00
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();
2026-02-15 11:12:58 +01:00
toggleTodoStepForm(false);
2026-02-14 02:26:29 +01:00
} 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);
2026-02-10 14:40:38 +01:00
}
}
2026-02-01 11:58:44 +01:00
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 {
2026-02-17 08:29:05 +01:00
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');
}
2026-02-01 11:58:44 +01:00
loadCaseLocations();
} catch (e) {
2026-02-17 08:29:05 +01:00
alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
2026-02-01 11:58:44 +01:00
}
}
// 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 >
2026-02-17 08:29:05 +01:00
<!-- 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" >
2026-03-05 08:41:59 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
< / div >
< / div >
{% endfor %}
< / div >
< / div >
2026-03-05 08:41:59 +01:00
{% 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 >
2026-02-06 10:47:14 +01:00
2026-02-10 14:40:38 +01:00
< div class = "card h-100 d-flex flex-column right-module-card" data-module = "wiki" data-has-content = "unknown" >
< div class = "card-header d-flex justify-content-between align-items-center" >
2026-02-15 11:12:58 +01:00
< h6 class = "mb-0" style = "color: var(--accent); font-size: 0.85rem;" > Kunde-wiki< / h6 >
2026-02-10 14:40:38 +01:00
< / 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" >
2026-02-15 11:12:58 +01:00
< div class = "p-3 text-center text-muted" > Henter wiki...< / div >
2026-02-10 14:40:38 +01:00
< / div >
< / div >
< / div >
2026-02-14 02:26:29 +01:00
< 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" >
2026-02-15 11:12:58 +01:00
< 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 >
2026-02-14 02:26:29 +01:00
< / div >
< div class = "card-body p-0 d-flex flex-column" style = "max-height: 260px;" >
2026-02-15 11:12:58 +01:00
< 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 >
2026-02-14 02:26:29 +01:00
< 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" >
2026-02-15 11:12:58 +01:00
< button class = "btn btn-sm btn-outline-primary" type = "submit" > Tilføj< / button >
2026-02-14 02:26:29 +01:00
< / div >
< / form >
< div class = "list-group list-group-flush flex-grow-1 overflow-auto" id = "todo-steps-list" >
2026-02-15 11:12:58 +01:00
< div class = "p-3 text-center text-muted" > Ingen opgaver endnu< / div >
2026-02-14 02:26:29 +01:00
< / div >
< / div >
< / div >
2026-02-06 10:47:14 +01:00
< / div >
< / div >
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div > <!-- End Details Tab -->
2026-03-03 14:33:11 +01:00
<!-- 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 = "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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- Solution Tab -->
2026-02-06 10:47:14 +01:00
< 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' }}" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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 %}
2026-02-01 11:58:44 +01:00
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< div class = "card-body" >
{% if nextcloud_instance %}
<!-- Info Row -->
< div class = "d-flex flex-wrap gap-4 mb-3 border-bottom pb-3" >
2026-02-01 11:58:44 +01:00
< div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2026-01-31 23:16:24 +01:00
< / div >
2026-02-01 11:58:44 +01:00
< div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< span class = "text-muted small d-block" > Admin Konto< / span >
< span class = "font-monospace text-dark" > {{ nextcloud_instance.username }}< / span >
2026-02-01 00:29:57 +01:00
< / div >
2026-01-31 23:16:24 +01:00
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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 >
2026-02-01 11:58:44 +01:00
< / div >
< / div >
{% else %}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< div class = "text-center py-2 text-muted" >
< i class = "bi bi-exclamation-triangle me-2 text-warning" > < / i >
Kunden mangler Nextcloud konfiguration
< / div >
2026-02-01 11:58:44 +01:00
{% endif %}
2026-01-31 23:16:24 +01:00
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / 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 -->
2026-02-06 10:47:14 +01:00
< div class = "tab-pane fade" id = "sales" role = "tabpanel" tabindex = "0" data-module = "sales" data-has-content = "unknown" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2026-01-31 23:16:24 +01:00
< / div >
2026-01-29 23:07:33 +01:00
< / div >
2026-02-01 11:58:44 +01:00
< / div >
< / div >
2026-01-29 23:07:33 +01:00
2026-02-08 12:42:19 +01:00
<!-- 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" >
2026-02-17 08:29:05 +01:00
< div class = "col-md-4" >
2026-02-08 12:42:19 +01:00
< label class = "small text-muted" > Abonnement< / label >
< div class = "fw-semibold" id = "subscriptionNumber" > -< / div >
< / div >
2026-02-17 08:29:05 +01:00
< div class = "col-md-4" >
2026-02-08 12:42:19 +01:00
< label class = "small text-muted" > Produkt< / label >
< div class = "fw-semibold" id = "subscriptionProduct" > -< / div >
< / div >
2026-02-17 08:29:05 +01:00
< 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" >
2026-02-08 12:42:19 +01:00
< label class = "small text-muted" > Interval< / label >
< div class = "fw-semibold" id = "subscriptionInterval" > -< / div >
< / div >
2026-02-17 08:29:05 +01:00
< div class = "col-md-4" >
2026-02-08 12:42:19 +01:00
< label class = "small text-muted" > Pris< / label >
< div class = "fw-semibold" id = "subscriptionPrice" > -< / div >
< / div >
2026-02-17 08:29:05 +01:00
< div class = "col-md-4" >
2026-02-08 12:42:19 +01:00
< label class = "small text-muted" > Startdato< / label >
< div class = "fw-semibold" id = "subscriptionStartDate" > -< / div >
< / div >
< div class = "col-md-6" >
2026-02-17 08:29:05 +01:00
< 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 >
2026-02-08 12:42:19 +01:00
< / 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 >
2026-02-17 08:29:05 +01:00
< option value = "daily" > Daglig< / option >
< option value = "biweekly" > Hver 14. dag< / option >
2026-02-08 12:42:19 +01:00
< 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 >
2026-02-17 08:29:05 +01:00
< option value = "daily" > Daglig< / option >
< option value = "biweekly" > Hver 14. dag< / option >
2026-02-08 12:42:19 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
<!-- 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 >
2026-02-14 02:26:29 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
2026-02-06 10:47:14 +01:00
< 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 >
2026-01-29 23:07:33 +01:00
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< / div >
< / div >
2026-02-06 10:47:14 +01:00
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div > <!-- End Tab Content -->
2026-02-06 10:47:14 +01:00
<!-- 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 >
2026-02-14 02:26:29 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
< 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" >
2026-02-15 11:12:58 +01:00
< label class = "form-check-label" for = "rem_notify_email" > E-mail< / label >
2026-02-06 10:47:14 +01:00
< / 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
<!-- 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 >
2026-02-01 11:58:44 +01:00
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2026-01-29 23:07:33 +01:00
< / div >
< / div >
< / div >
< / div >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 || []);
2026-02-14 02:26:29 +01:00
const hasSalesData = (data.sale_items || []).length > 0 || (data.time_entries || []).length > 0;
setModuleContentState('sales', hasSalesData);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
} 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 > ';
}
2026-02-14 02:26:29 +01:00
setModuleContentState('sales', true);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
}
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);
}
}
2026-02-06 10:47:14 +01:00
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 '-';
2026-02-14 02:26:29 +01:00
return date.toLocaleString('da-DK', { hour12: false });
2026-02-06 10:47:14 +01:00
}
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');
}
2026-02-14 02:26:29 +01:00
function openCreateReminderModal(defaultEventType) {
2026-02-06 10:47:14 +01:00
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';
2026-02-14 02:26:29 +01:00
document.getElementById('rem_event_type').value = defaultEventType || 'reminder';
2026-02-06 10:47:14 +01:00
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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('reminders', true);
2026-02-06 10:47:14 +01:00
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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('reminders', true);
2026-02-06 10:47:14 +01:00
}
}
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 > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('reminders', false);
2026-02-06 10:47:14 +01:00
return;
}
const triggerLabels = {
time_based: 'Tidspunkt',
status_change: 'Status ændring',
deadline_approaching: 'Deadline'
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
};
2026-02-14 02:26:29 +01:00
const eventTypeLabels = {
reminder: 'Reminder',
meeting: 'Moede',
technician_visit: 'Teknikerbesoeg',
obs: 'OBS',
deadline: 'Deadline'
};
2026-02-06 10:47:14 +01:00
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" >
2026-02-14 02:26:29 +01:00
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}
2026-02-06 10:47:14 +01:00
< / 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('');
2026-02-14 02:26:29 +01:00
setModuleContentState('reminders', true);
2026-02-06 10:47:14 +01:00
}
async function saveReminder() {
reminderUserId = getReminderUserId();
if (!reminderUserId) {
alert('Mangler bruger-id. Log ind igen.');
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
return;
}
2026-02-06 10:47:14 +01:00
const title = document.getElementById('rem_title').value.trim();
const message = document.getElementById('rem_message').value.trim();
const priority = document.getElementById('rem_priority').value;
2026-02-14 02:26:29 +01:00
const eventType = document.getElementById('rem_event_type').value;
2026-02-06 10:47:14 +01:00
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;
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
2026-02-06 10:47:14 +01:00
if (!title) {
alert('Titel er påkrævet');
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
return;
}
2026-02-06 10:47:14 +01:00
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,
2026-02-14 02:26:29 +01:00
event_type: eventType,
2026-02-06 10:47:14 +01:00
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();
2026-02-14 02:26:29 +01:00
await loadCaseCalendar();
2026-02-06 10:47:14 +01:00
} catch (e) {
alert('Fejl: ' + e.message);
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
2026-02-06 10:47:14 +01:00
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();
2026-02-14 02:26:29 +01:00
await loadCaseCalendar();
2026-02-06 10:47:14 +01:00
} catch (e) {
alert('Fejl: ' + e.message);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
}
2026-02-14 02:26:29 +01:00
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);
}
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
document.addEventListener('DOMContentLoaded', function() {
2026-02-06 10:47:14 +01:00
updateReminderTriggerFields();
updateReminderRecurrenceFields();
loadReminders();
2026-02-14 02:26:29 +01:00
loadCaseCalendar();
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
});
< / 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 -->
2026-02-08 01:45:00 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / 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;
2026-02-08 01:45:00 +01:00
let fixedPriceAgreementId = null;
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
// Handle prepaid card selection formatting (card_123)
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
2026-02-08 01:45:00 +01:00
// Handle fixed-price agreement selection formatting (fpa_123)
if (billingMethod.startsWith('fpa_')) {
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'fixed_price';
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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;
}
2026-02-08 01:45:00 +01:00
if (fixedPriceAgreementId) {
data.fixed_price_agreement_id = fixedPriceAgreementId;
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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" >
2026-02-15 11:12:58 +01:00
< label class = "small text-muted mb-1" > E-mail< / label >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 %}
2026-02-14 02:26:29 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
{% 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" >
2026-02-15 11:12:58 +01:00
< label class = "form-label" > E-mail< / label >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2026-02-17 08:29:05 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / select >
< / div >
2026-02-17 08:29:05 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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); }
}
2026-02-06 10:47:14 +01:00
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);
}
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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); }
}
2026-02-06 10:47:14 +01:00
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);
}
}
2026-02-17 08:29:05 +01:00
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);
}
2026-02-06 10:47:14 +01:00
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);
}
2026-02-15 11:12:58 +01:00
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');
}
2026-02-17 08:29:05 +01:00
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);
}
}
2026-03-05 08:41:59 +01:00
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');
}
}
2026-02-17 08:29:05 +01:00
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}`;
}
}
2026-02-15 11:12:58 +01:00
}
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) {
2026-02-17 08:29:05 +01:00
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})`);
2026-02-15 11:12:58 +01:00
}
window.location.reload();
} catch (error) {
alert(`Fejl: ${error.message}`);
}
}
2026-02-06 10:47:14 +01:00
// ==========================================
// VIEW CONTROL (Tag-based)
// ==========================================
let modulePrefs = {};
2026-02-14 02:26:29 +01:00
let currentCaseView = 'Sag-detalje';
2026-02-06 10:47:14 +01:00
function moduleHasContent(el) {
const attr = el.getAttribute('data-has-content');
if (attr === 'true') return true;
if (attr === 'false') return false;
2026-02-14 02:26:29 +01:00
if (attr === 'unknown') return false;
2026-02-06 10:47:14 +01:00
if (el.querySelector('.person-card')) return true;
if (el.querySelector('.list-group-item')) return true;
return true;
}
2026-02-14 02:26:29 +01:00
function setModuleContentState(moduleKey, hasContent) {
const el = document.querySelector(`[data-module="${moduleKey}"]`);
if (!el) return;
el.setAttribute('data-has-content', hasContent ? 'true' : 'false');
applyViewLayout(currentCaseView);
}
2026-02-06 10:47:14 +01:00
function applyViewLayout(viewName) {
if (!viewName) return;
2026-02-14 02:26:29 +01:00
currentCaseView = viewName;
2026-02-06 10:47:14 +01:00
document.body.setAttribute('data-case-view', viewName);
const viewDefaults = {
2026-02-15 11:12:58 +01:00
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
2026-02-10 14:40:38 +01:00
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki'],
2026-02-15 11:12:58 +01:00
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
2026-02-06 10:47:14 +01:00
};
2026-02-15 11:12:58 +01:00
const defaultsByCaseType = caseTypeModuleDefaults[caseTypeKey];
const standardModules = Array.isArray(defaultsByCaseType) & & defaultsByCaseType.length > 0
? defaultsByCaseType
: (viewDefaults[viewName] || []);
const standardModuleSet = new Set(standardModules);
2026-02-06 10:47:14 +01:00
document.querySelectorAll('[data-module]').forEach((el) => {
const moduleName = el.getAttribute('data-module');
const hasContent = moduleHasContent(el);
2026-02-17 08:29:05 +01:00
const isTimeModule = moduleName === 'time';
const shouldCompactWhenEmpty = moduleName !== 'wiki' & & moduleName !== 'pipeline' & & !isTimeModule;
2026-02-06 10:47:14 +01:00
const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
2026-03-05 08:41:59 +01:00
// 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;
}
2026-02-17 08:29:05 +01:00
2026-03-05 08:41:59 +01:00
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);
2026-02-15 11:12:58 +01:00
el.classList.remove('module-empty-compact');
return;
}
2026-03-05 08:41:59 +01:00
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
2026-02-06 10:47:14 +01:00
if (pref === false) {
2026-03-05 08:41:59 +01:00
setVisibility(false);
2026-02-15 11:12:58 +01:00
el.classList.remove('module-empty-compact');
2026-02-06 10:47:14 +01:00
return;
}
2026-03-05 08:41:59 +01:00
// HVIS specifik præference aktiverer den (brugervalg)
2026-02-06 10:47:14 +01:00
if (pref === true) {
2026-03-05 08:41:59 +01:00
setVisibility(true);
2026-02-15 11:12:58 +01:00
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty & & !hasContent);
2026-02-06 10:47:14 +01:00
return;
}
2026-03-05 08:41:59 +01:00
// 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);
2026-02-06 10:47:14 +01:00
} else {
2026-03-05 08:41:59 +01:00
setVisibility(false);
2026-02-15 11:12:58 +01:00
el.classList.remove('module-empty-compact');
2026-02-06 10:47:14 +01:00
}
});
updateRightColumnVisibility();
2026-03-05 08:41:59 +01:00
updateInnerColumnVisibility();
2026-02-06 10:47:14 +01:00
}
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');
}
}
2026-03-05 08:41:59 +01:00
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');
}
}
2026-02-06 10:47:14 +01:00
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;
}, {});
2026-02-17 08:29:05 +01:00
modulePrefs.time = true;
2026-02-06 10:47:14 +01:00
} catch (e) {
console.error('Module prefs load failed', e);
}
}
2026-02-15 11:12:58 +01:00
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 = {};
}
}
2026-02-06 10:47:14 +01:00
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');
2026-02-15 11:12:58 +01:00
return { key, label: window.moduleDisplayNames[key] || key };
2026-02-06 10:47:14 +01:00
});
list.innerHTML = modules.map(m => {
2026-02-17 08:29:05 +01:00
const isTimeModule = m.key === 'time';
const checked = isTimeModule ? true : modulePrefs[m.key] !== false;
2026-02-06 10:47:14 +01:00
return `
< div class = "form-check mb-2" >
< input class = "form-check-input" type = "checkbox" id = "module_${m.key}" $ { checked ? ' checked ' : ' ' }
2026-02-17 08:29:05 +01:00
${isTimeModule ? 'disabled' : ''}
2026-02-06 10:47:14 +01:00
onchange="toggleModulePref('${m.key}', this.checked)">
2026-02-17 08:29:05 +01:00
< label class = "form-check-label" for = "module_${m.key}" > ${m.label}${isTimeModule ? ' (altid synlig)' : ''}< / label >
2026-02-06 10:47:14 +01:00
< / div >
`;
}).join('');
const modal = new bootstrap.Modal(document.getElementById('moduleControlModal'));
modal.show();
}
async function toggleModulePref(moduleKey, isEnabled) {
2026-02-17 08:29:05 +01:00
if (moduleKey === 'time') {
modulePrefs.time = true;
applyViewFromTags();
return;
}
2026-02-06 10:47:14 +01:00
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);
}
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
// ==========================================
// FILES & EMAILS LOGIC
// ==========================================
// ---------------- FILES ----------------
async function loadSagFiles() {
const container = document.getElementById('files-list');
if(!container) return;
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();
renderFiles(files);
} else {
container.innerHTML = '< div class = "p-3 text-center text-danger" > Fejl ved hentning af filer< / div > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('files', true);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
} catch(e) {
console.error(e);
container.innerHTML = '< div class = "p-3 text-center text-danger" > Fejl ved hentning af filer< / div > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('files', true);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
}
function renderFiles(files) {
const container = document.getElementById('files-list');
if(!files || files.length === 0) {
container.innerHTML = '< div class = "p-3 text-center text-muted" > Ingen filer fundet...< / div > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('files', false);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
return;
}
2026-02-14 02:26:29 +01:00
setModuleContentState('files', true);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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;" >
2026-02-06 10:47:14 +01:00
< a href = "javascript:void(0);" onclick = "previewFile(${f.id}, '${f.filename.replace(/'/g, " \ \ ' " ) } ' , ' $ { f . content_type | | ' ' } ' ) " class = "text-decoration-none text-dark" >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< 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 >
2026-02-06 10:47:14 +01:00
< 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 >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / 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); }
}
2026-02-06 10:47:14 +01:00
// 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;
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
// 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 ----------------
2026-03-03 14:33:11 +01:00
let linkedEmailsCache = [];
let selectedLinkedEmailId = null;
function openCaseEmailTab() {
const trigger = document.getElementById('emails-tab');
if (!trigger) return;
const instance = bootstrap.Tab.getOrCreateInstance(trigger);
instance.show();
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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) {
2026-03-03 14:33:11 +01:00
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();
}
2026-02-14 02:26:29 +01:00
} else {
container.innerHTML = '< div class = "p-3 text-center text-danger" > Fejl ved hentning af emails< / div > ';
setModuleContentState('emails', true);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
2026-02-14 02:26:29 +01:00
} catch(e) {
console.error(e);
container.innerHTML = '< div class = "p-3 text-center text-danger" > Fejl ved hentning af emails< / div > ';
setModuleContentState('emails', true);
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
2026-03-03 14:33:11 +01:00
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);
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
function renderLinkedEmails(emails) {
const container = document.getElementById('linked-emails-list');
2026-03-03 14:33:11 +01:00
if (!container) return;
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
if(!emails || emails.length === 0) {
2026-02-15 11:12:58 +01:00
container.innerHTML = '< div class = "p-3 text-center text-muted" > Ingen linkede e-mails...< / div > ';
2026-02-14 02:26:29 +01:00
setModuleContentState('emails', false);
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
return;
}
2026-02-14 02:26:29 +01:00
setModuleContentState('emails', true);
2026-03-03 14:33:11 +01:00
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;
2026-03-03 10:42:16 +01:00
return `
2026-03-03 14:33:11 +01:00
< 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" >
2026-03-03 10:42:16 +01:00
< i class = "bi bi-link-45deg" style = "text-decoration: line-through;" > < / i >
2026-03-03 14:33:11 +01:00
< / span >
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
< / div >
< / div >
2026-03-03 14:33:11 +01:00
< / button >
2026-03-03 10:42:16 +01:00
`;
}).join('');
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
}
2026-03-03 14:33:11 +01:00
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 > ';
}
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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' });
2026-03-03 14:33:11 +01:00
if(res.ok) {
if (Number(selectedLinkedEmailId) === Number(emailId)) {
selectedLinkedEmailId = null;
renderEmailPreviewEmpty();
}
loadLinkedEmails();
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
} 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';
}
});
}
2026-03-03 14:33:11 +01:00
['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();
}
});
});
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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) {
2026-03-03 14:33:11 +01:00
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;
}
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
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', () => {
loadSagFiles();
loadLinkedEmails();
});
< / script >
2026-02-08 12:42:19 +01:00
< script >
const subscriptionCaseId = {{ case.id }};
let currentSubscription = null;
let subscriptionProducts = [];
let lastCreatedSubscriptionProductId = null;
function formatSubscriptionInterval(interval) {
const map = {
2026-02-17 08:29:05 +01:00
'daily': 'Daglig',
'biweekly': '14-dage',
2026-02-08 12:42:19 +01:00
'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 || '-';
2026-02-17 08:29:05 +01:00
// 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 > `;
}
}
}
2026-02-08 12:42:19 +01:00
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 {
2026-02-17 08:29:05 +01:00
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
2026-02-08 12:42:19 +01:00
if (res.status === 404) {
showSubscriptionCreateForm();
2026-02-14 02:26:29 +01:00
setModuleContentState('subscription', false);
2026-02-08 12:42:19 +01:00
return;
}
if (!res.ok) {
throw new Error('Kunne ikke hente abonnement');
}
const subscription = await res.json();
renderSubscription(subscription);
2026-02-14 02:26:29 +01:00
setModuleContentState('subscription', true);
2026-02-08 12:42:19 +01:00
} catch (e) {
console.error('Error loading subscription:', e);
showSubscriptionCreateForm();
2026-02-14 02:26:29 +01:00
setModuleContentState('subscription', true);
2026-02-08 12:42:19 +01:00
}
}
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 {
2026-02-17 08:29:05 +01:00
const res = await fetch('/api/v1/sag-subscriptions', {
2026-02-08 12:42:19 +01:00
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 {
2026-02-17 08:29:05 +01:00
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
2026-02-08 12:42:19 +01:00
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();
});
2026-02-17 08:29:05 +01:00
// === 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;
}
2026-03-05 08:41:59 +01:00
// 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(); }
2026-02-17 08:29:05 +01:00
});
2026-03-05 08:41:59 +01:00
// 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');
}
})();
2026-02-08 12:42:19 +01:00
< / script >
2026-02-01 11:58:44 +01:00
< / div >
{% endblock %}