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);
}
2026-02-17 12:49:11 +01:00
/* Wider tooltip for relation type explanations */
.tooltip-wide .tooltip-inner {
max-width: 400px;
text-align: left;
}
2026-02-01 11:58:44 +01:00
.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-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
}
.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 %}
< div class = "container-fluid" style = "margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;" >
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-02-15 11:12:58 +01:00
<!-- Quick Info Bar (Redesigned) -->
2026-02-01 11:58:44 +01:00
< div class = "card mb-3" style = "background: var(--bg-card); border-left: 4px solid var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08);" >
< div class = "card-body py-2 px-3" >
2026-02-15 11:12:58 +01:00
< div class = "d-flex flex-wrap align-items-center column-gap-4 row-gap-2" style = "font-size: 0.85rem;" >
2026-02-01 11:58:44 +01:00
< div class = "d-flex align-items-center" >
< strong style = "color: var(--accent); margin-right: 0.4rem;" > ID:< / strong >
< span > {{ case.id }}< / span >
< / div >
2026-02-15 11:12:58 +01:00
< div class = "d-flex align-items-center ps-3 border-start" >
2026-02-01 11:58:44 +01:00
< strong style = "color: var(--accent); margin-right: 0.4rem;" > Kunde:< / strong >
2026-02-15 11:12:58 +01:00
{% if customer %}
< a href = "/customers/{{ customer.id }}" style = "color: inherit; text-decoration: underline;" >
{{ customer.name }}
< / a >
{% else %}
< span > Ingen kunde< / span >
{% endif %}
2026-02-01 11:58:44 +01:00
< / div >
2026-02-15 11:12:58 +01:00
< div class = "d-flex align-items-center ps-3 border-start" >
< strong style = "color: var(--accent); margin-right: 0.4rem;" > Kontakt:< / strong >
2026-02-01 11:58:44 +01:00
{% if hovedkontakt %}
< span style = "cursor: pointer; text-decoration: underline; color: var(--accent);"
onclick="showKontaktModal()"
2026-02-15 11:12:58 +01:00
title="Se kontaktinfo">
2026-02-01 11:58:44 +01:00
{{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }}
< / span >
{% else %}
2026-02-15 11:12:58 +01:00
< span class = "text-muted fst-italic" > Ingen< / span >
2026-02-01 11:58:44 +01:00
{% endif %}
< / div >
2026-02-15 11:12:58 +01:00
< div class = "d-flex align-items-center ps-3 border-start" >
2026-02-01 11:58:44 +01:00
< strong style = "color: var(--accent); margin-right: 0.4rem;" > Afdeling:< / strong >
< span style = "cursor: pointer; text-decoration: underline; color: var(--accent);"
onclick="showAfdelingModal()"
2026-02-15 11:12:58 +01:00
title="Ændre afdeling">
2026-02-01 11:58:44 +01:00
{{ customer.department if customer and customer.department else 'N/A' }}
< / span >
< / div >
2026-02-15 11:12:58 +01:00
< div class = "d-flex align-items-center ps-3 border-start" >
2026-02-01 11:58:44 +01:00
< strong style = "color: var(--accent); margin-right: 0.4rem;" > Status:< / strong >
< span class = "badge" style = "background: var(--accent); font-size: 0.75rem;" > {{ case.status }}< / span >
< / div >
2026-02-15 11:12:58 +01:00
<!-- Dates Group -->
< div class = "d-flex align-items-center ps-3 border-start" >
< strong style = "color: var(--accent); margin-right: 0.4rem;" title = "Oprettet / Opdateret" > Datoer:< / strong >
< span class = "text-muted" style = "margin-right: 0.3rem;" > Opr:< / span > {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }}
< span class = "text-muted mx-2" > |< / span >
< span class = "text-muted" style = "margin-right: 0.3rem;" > Opd:< / span > {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }}
2026-02-17 08:29:05 +01:00
< / div >
< div class = "d-flex align-items-center ps-3 border-start" >
< strong style = "color: var(--accent); margin-right: 0.4rem;" > Deadline:< / strong >
{% if case.deadline %}
< span class = "badge bg-light text-dark border me-1 {{ 'text-danger border-danger' if is_deadline_overdue else '' }}" >
< i class = "bi bi-clock me-1" > < / i > {{ case.deadline.strftime('%d/%m-%y') }}
< / span >
{% else %}
< span class = "text-muted fst-italic me-1" > Ingen< / span >
{% endif %}
< button class = "btn btn-link btn-sm p-0 text-muted" onclick = "openDeadlineModal()" title = "Rediger deadline" >
< i class = "bi bi-pencil-square" > < / i >
< / button >
2026-02-01 11:58:44 +01:00
< / div >
2026-02-15 11:12:58 +01:00
<!-- Deferred Logic integrated -->
< div class = "d-flex align-items-center ps-3 border-start" >
< strong style = "color: var(--accent); margin-right: 0.4rem;" > Udsat:< / strong >
{% if case.deferred_until %}
< span class = "badge bg-light text-dark border me-1" >
< i class = "bi bi-calendar-event me-1" > < / i > {{ case.deferred_until.strftime('%d/%m-%y') }}
< / span >
{% else %}
< span class = "text-muted fst-italic me-1" > Nej< / span >
{% endif %}
< button class = "btn btn-link btn-sm p-0 text-muted" onclick = "openDeferredModal()" title = "Rediger udsættelse" >
< i class = "bi bi-pencil-square" > < / i >
< / button >
2026-02-01 11:58:44 +01:00
< / div >
< / div >
< / div >
< / div >
2026-02-17 08:29:05 +01:00
<!-- Assignment Card -->
< div class = "card mb-3" style = "background: var(--bg-card); box-shadow: 0 1px 3px rgba(0,0,0,0.08);" >
< div class = "card-body py-2 px-3" >
< div class = "row g-3 align-items-end" >
< div class = "col-md-5" >
< label class = "form-label small text-muted" > Ansvarlig medarbejder< / label >
< select id = "assignmentUserSelect" class = "form-select form-select-sm" >
< option value = "" > Ingen< / option >
{% for user in assignment_users or [] %}
< option value = "{{ user.user_id }}" { % if case . ansvarlig_bruger_id = = user . user_id % } selected { % endif % } > {{ user.display_name }}< / option >
{% endfor %}
< / select >
< / div >
< div class = "col-md-5" >
< label class = "form-label small text-muted" > Ansvarlig gruppe< / label >
< select id = "assignmentGroupSelect" class = "form-select form-select-sm" >
< option value = "" > Ingen< / option >
{% for group in assignment_groups or [] %}
< option value = "{{ group.id }}" { % if case . assigned_group_id = = group . id % } selected { % endif % } > {{ group.name }}< / option >
{% endfor %}
< / select >
< / div >
< div class = "col-md-2 d-flex justify-content-end" >
< button class = "btn btn-sm btn-primary" onclick = "saveAssignment()" > Gem tildeling< / button >
< / div >
< / div >
< div id = "assignmentStatus" class = "small text-muted mt-2" > < / 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
<!-- 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 >
< 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" >
< div class = "col-lg-8" id = "case-left-column" >
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
<!-- ROW 1: Main Info -->
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 mb-3" >
<!-- Main Case Info -->
2026-02-06 10:47:14 +01:00
< div class = "col-12 mb-3" >
< div class = "card h-100 d-flex flex-column case-summary-card" >
< div class = "card-header case-summary-header" >
2026-02-01 11:58:44 +01:00
< div >
2026-02-06 10:47:14 +01:00
< div class = "case-summary-title" > {{ case.titel }}< / div >
< div class = "case-summary-meta" >
< span class = "case-pill" > #{{ case.id }}< / span >
< span class = "case-pill" > {{ case.status }}< / span >
2026-02-15 11:12:58 +01:00
< span class = "case-pill case-pill-muted" > {{ case.template_key or case.type or 'ticket' }}< / span >
2026-02-06 10:47:14 +01:00
< / div >
< / div >
< div class = "d-flex align-items-center gap-2" >
< a href = "/sag/{{ case.id }}/edit" class = "btn btn-sm btn-outline-primary" >
2026-02-01 11:58:44 +01:00
< i class = "bi bi-pencil" > < / i >
< / a >
< button class = "btn btn-sm btn-outline-danger" onclick = "confirmDeleteCase()" >
< i class = "bi bi-trash" > < / i >
< / button >
< / div >
< / div >
2026-02-06 10:47:14 +01:00
< div class = "card-body case-summary-body" >
2026-02-15 11:12:58 +01:00
<!-- Metadata moved to top bar -->
< div class = "case-summary-desc mt-0 pt-2 border-0 bg-transparent ps-0" >
< div class = "summary-label mb-2" > Beskrivelse< / div >
< div class = "summary-value" style = "font-size: 0.95rem; white-space: pre-wrap;" > {{ case.beskrivelse or 'Ingen beskrivelse' }}< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- ROW 1B: Pipeline -->
< div class = "row mb-3" >
< div class = "col-12 mb-3" >
< div class = "card h-100 d-flex flex-column" data-module = "pipeline" data-has-content = "{{ 'true' if case.pipeline_stage_id or case.pipeline_amount or case.pipeline_probability else 'false' }}" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< h6 class = "mb-0" style = "color: var(--accent);" > 📈 Salgspipeline< / h6 >
< button id = "pipelineEditToggle" class = "btn btn-sm btn-outline-primary" onclick = "togglePipelineEdit()" >
< i class = "bi bi-pencil" > < / i >
< / button >
< / div >
< div class = "card-body" >
< div id = "pipelineViewMode" >
< div class = "row g-3" >
< div class = "col-md-4" >
< div class = "summary-item h-100" >
< div class = "summary-label" > Stage< / div >
< div class = "summary-value" >
{% set ns = namespace(selected_stage=None) %}
{% for stage in pipeline_stages or [] %}
{% if case.pipeline_stage_id == stage.id %}
{% set ns.selected_stage = stage %}
{% endif %}
{% endfor %}
{% if ns.selected_stage %}
< span class = "badge" style = "background: {{ ns.selected_stage.color or '#0f4c75' }};" > {{ ns.selected_stage.name }}< / span >
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-02-06 10:47:14 +01:00
<!-- ROW 2: 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 = "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-17 12:49:11 +01:00
< h6 class = "mb-0" style = "color: var(--accent);" >
🔗 Relationer
< i class = "bi bi-info-circle-fill ms-2"
style="font-size: 0.85rem; cursor: help; opacity: 0.7;"
data-bs-toggle="tooltip"
data-bs-placement="right"
data-bs-html="true"
title="< div class = 'text-start' > < 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.< / div > ">< / i >
< / h6 >
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-02-15 11:12:58 +01:00
< div class = "d-flex align-items-center flex-grow-1 relation-node-card {{ 'fw-bold bg-white shadow-sm border-primary' if node.is_current else '' }} rounded px-2 py-1" style = "min-height: 36px;" >
<!-- 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' %}
{% set rel_help = 'Denne sag er årsag 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 -->
< a href = "/sag/{{ node.case.id }}" class = "text-decoration-none d-flex align-items-center {{ 'text-dark' if node.is_current else 'text-secondary' }} text-truncate" >
< 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-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 %}
<!-- Actions -->
2026-02-06 10:47:14 +01:00
{% if node.relation_id %}
2026-02-15 11:12:58 +01:00
< div class = "ms-auto ps-2" >
< button onclick = "deleteRelation({{ node.relation_id }})" class = "btn btn-sm btn-link text-danger p-0 opacity-50 hover-opacity-100" title = "Fjern relation" >
< i class = "bi bi-x-lg" > < / i >
< / button >
< / div >
2026-02-06 10:47:14 +01:00
{% endif %}
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 %}
{% if relation_tree %}
< 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 %}
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
< p class = "text-muted text-center pt-3" > Ingen relaterede sager< / p >
2026-02-01 11:58:44 +01:00
{% endif %}
< / div >
< / div >
< / div >
< / div >
2026-02-14 02:26:29 +01:00
<!-- ROW: Call History -->
< 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" >
2026-02-15 11:12:58 +01:00
< h6 class = "mb-0" style = "color: var(--accent);" > 📞 Opkaldshistorik< / h6 >
2026-02-14 02:26:29 +01:00
< 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 >
< script >
let caseCurrentUserId = null;
async function ensureCaseCurrentUserId() {
if (caseCurrentUserId !== null) return caseCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
caseCurrentUserId = Number(me?.id) || null;
return caseCurrentUserId;
} catch (e) {
return null;
}
}
async function ringOutFromCase(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureCaseCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
< / script >
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-02-14 02:26:29 +01:00
< div class = "col-md-6 mb-3" >
< 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 >
<!-- Linked Emails -->
2026-02-14 02:26:29 +01:00
< div class = "col-md-6 mb-3" >
< div class = "card h-100" data-module = "emails" 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" >
2026-02-15 11:12:58 +01:00
< h6 class = "mb-0" style = "color: var(--accent);" > 📧 Linkede e-mails< / h6 >
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 >
<!-- Email Drop Zone -->
< div class = "card-body p-0 d-flex flex-column" id = "emailDropZone" >
< div class = "p-3 border-bottom bg-light position-relative" >
2026-02-15 11:12:58 +01:00
< input type = "text" class = "form-control form-control-sm" id = "emailSearchInput" placeholder = "Søg og link e-mail..." autocomplete = "off" >
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 = "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 = "text-center p-2 small text-muted fst-italic border-bottom" >
Træk .msg/.eml filer hertil for at importere
< / div >
< div class = "list-group list-group-flush flex-grow-1 overflow-auto" id = "linked-emails-list" style = "max-height: 250px;" >
2026-02-15 11:12:58 +01:00
< div class = "p-3 text-center text-muted" > Ingen e-mails linket...< / 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', () => {
2026-02-17 12:49:11 +01:00
// Initialize Bootstrap tooltips
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
new bootstrap.Tooltip(el, {
customClass: 'tooltip-wide'
});
});
2026-02-01 11:58:44 +01:00
// 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-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" >
< div class = "small text-muted" > Kort #{{ card.card_number or card.id }}< / div >
< div class = "fw-bold text-primary" > {{ '%.2f' % card.remaining_hours }} timer tilbage< / div >
< / div >
< / div >
{% endfor %}
< / div >
< / div >
{% endif %}
< / div >
< / div >
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
< / div >
< div class = "col-lg-4" id = "case-right-column" >
< div class = "right-modules-grid" >
< div class = "card d-flex flex-column h-100 right-module-card" data-module = "hardware" data-has-content = "unknown" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< h6 class = "mb-0" style = "color: var(--accent);" > 💻 Hardware< / h6 >
< button class = "btn btn-sm btn-outline-primary" onclick = "openSearchModal('hardware')" >
< i class = "bi bi-link-45deg" > < / i >
< / button >
< / div >
< div class = "card-body flex-grow-1 overflow-auto p-0" style = "max-height: 180px;" >
< div class = "list-group list-group-flush" id = "hardware-list" >
< div class = "p-3 text-center text-muted" > Henter hardware...< / div >
< / div >
< / div >
< / div >
< div class = "card h-100 d-flex flex-column right-module-card" data-module = "locations" data-has-content = "unknown" >
< div class = "card-header d-flex justify-content-between align-items-center" >
< h6 class = "mb-0" style = "color: var(--accent);" > 📍 Lokationer< / h6 >
< button class = "btn btn-sm btn-outline-primary" onclick = "openSearchModal('location')" >
< i class = "bi bi-plus-lg" > < / i >
< / button >
< / div >
< div class = "card-body flex-grow-1 overflow-auto p-0" style = "max-height: 180px;" >
< div class = "list-group list-group-flush" id = "locations-list" >
< div class = "p-3 text-center text-muted" > Henter lokationer...< / div >
< / div >
< / div >
< / div >
< div 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 >
2026-02-15 11:12:58 +01:00
< span > Titel< / span >
2026-02-06 10:47:14 +01:00
< span > Kunde< / span >
< span > Slet< / span >
< / div >
{% for contact in contacts %}
< div
class="contact-row"
role="button"
tabindex="0"
onclick="showContactInfoModal(this)"
data-contact-id="{{ contact.contact_id }}"
data-name="{{ contact.contact_name|replace('"', '" ') }}"
data-title="{{ contact.title|default('', true)|replace('"', '" ') }}"
data-company="{{ contact.customer_name|default('', true)|replace('"', '" ') }}"
data-email="{{ contact.contact_email|default('', true)|replace('"', '" ') }}"
data-phone="{{ contact.phone|default('', true)|replace('"', '" ') }}"
data-mobile="{{ contact.mobile|default('', true)|replace('"', '" ') }}"
data-role="{{ contact.role|default('Kontakt')|replace('"', '" ') }}"
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
>
< div class = "contact-name" > {{ contact.contact_name }}< / div >
< small > {{ contact.title or '-' }}< / small >
< small > {{ contact.customer_name or '-' }}< / small >
< button
class="btn btn-sm btn-delete"
onclick="event.stopPropagation(); removeContact({{ case.id }}, {{ contact.contact_id }})"
title="Slet"
>
✕
< / button >
< / div >
{% endfor %}
{% else %}
< p class = "text-muted text-center" > Ingen kontakter< / p >
{% endif %}
< / div >
< / div >
< div class = "card 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 >
2026-02-15 11:12:58 +01:00
< span > E-mail< / span >
2026-02-06 10:47:14 +01:00
< span > Slet< / span >
< / div >
{% for customer in customers %}
< div class = "customer-row" >
< div >
< a href = "/customers/{{ customer.customer_id }}" class = "text-decoration-none fw-semibold" >
{{ customer.customer_name }}
< / a >
< / div >
< small > {{ customer.role or '-' }}< / small >
< small > {{ customer.customer_email or '-' }}< / small >
< button onclick = "removeCustomer({{ case.id }}, {{ customer.customer_id }})" class = "btn btn-sm btn-delete" title = "Slet" > ✕< / button >
< / div >
{% endfor %}
{% else %}
< p class = "text-muted text-center" > Ingen kunder< / p >
{% endif %}
< / div >
< / div >
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 -->
<!-- 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);
}
}
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-02-17 08:29:05 +01:00
if (isTimeModule) {
el.classList.remove('d-none');
el.classList.remove('module-empty-compact');
if (tabButton) tabButton.classList.remove('d-none');
return;
}
2026-02-15 11:12:58 +01:00
if (hasContent) {
el.classList.remove('d-none');
el.classList.remove('module-empty-compact');
if (tabButton) tabButton.classList.remove('d-none');
return;
}
2026-02-06 10:47:14 +01:00
if (pref === false) {
el.classList.add('d-none');
2026-02-15 11:12:58 +01:00
el.classList.remove('module-empty-compact');
2026-02-06 10:47:14 +01:00
if (tabButton) tabButton.classList.add('d-none');
return;
}
if (pref === true) {
el.classList.remove('d-none');
2026-02-15 11:12:58 +01:00
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty & & !hasContent);
2026-02-06 10:47:14 +01:00
if (tabButton) tabButton.classList.remove('d-none');
return;
}
2026-02-14 02:26:29 +01:00
if (!hasContent) {
2026-02-15 11:12:58 +01:00
if (standardModuleSet.has(moduleName)) {
el.classList.remove('d-none');
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty);
if (tabButton) tabButton.classList.remove('d-none');
} else {
el.classList.add('d-none');
el.classList.remove('module-empty-compact');
if (tabButton) tabButton.classList.add('d-none');
}
2026-02-06 10:47:14 +01:00
} else {
el.classList.remove('d-none');
2026-02-15 11:12:58 +01:00
el.classList.remove('module-empty-compact');
2026-02-06 10:47:14 +01:00
if (tabButton) tabButton.classList.remove('d-none');
}
});
updateRightColumnVisibility();
}
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');
}
}
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 ----------------
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) {
const emails = await res.json();
renderLinkedEmails(emails);
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
}
function renderLinkedEmails(emails) {
const container = document.getElementById('linked-emails-list');
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);
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 = emails.map(e => `
< div class = "list-group-item" >
< div class = "d-flex justify-content-between align-items-start" >
< div class = "text-truncate" >
< i class = "bi bi-envelope text-primary me-1" > < / i >
< strong > ${e.subject || '(Ingen emne)'}< / strong >
< div class = "small text-muted text-truncate" > ${e.sender_email}< / div >
< / div >
< button class = "btn btn-sm btn-link text-danger p-0 ms-2" onclick = "unlinkEmail(${e.id})" >
< i class = "bi bi-link-45deg" style = "text-decoration: line-through;" > < / i >
< / button >
< / div >
< / div >
`).join('');
}
async function unlinkEmail(emailId) {
if(!confirm("Fjern link til denne email?")) return;
try {
const res = await fetch(`/api/v1/sag/${caseIds}/email-links/${emailId}`, { method: 'DELETE' });
if(res.ok) loadLinkedEmails();
} catch(e) { alert(e); }
}
// Email Search
const emailSearchInput = document.getElementById('emailSearchInput');
const emailSearchResults = document.getElementById('emailSearchResults');
let emailDebounce = null;
if(emailSearchInput) {
emailSearchInput.addEventListener('input', e => {
clearTimeout(emailDebounce);
const q = e.target.value.trim();
if(q.length < 2 ) {
emailSearchResults.style.display = 'none';
return;
}
emailDebounce = setTimeout(() => searchEmails(q), 300);
});
// Hide on outside click
document.addEventListener('click', e => {
if(!emailSearchInput.contains(e.target) & & !emailSearchResults.contains(e.target)) {
emailSearchResults.style.display = 'none';
}
});
}
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) {
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-02-08 12:42:19 +01:00
< / script >
2026-02-01 11:58:44 +01:00
< / div >
{% endblock %}