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

6525 lines
306 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}{{ case.titel }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
text-decoration: underline;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.8rem 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: var(--text-secondary);
min-width: 150px;
}
.info-value {
color: var(--text-primary);
}
.tag {
display: inline-block;
background: var(--accent-light);
color: var(--accent);
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.person-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--bg-body);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.person-info strong {
display: block;
color: var(--accent);
}
.person-info small {
color: var(--text-secondary);
display: block;
}
.action-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 2rem;
}
.btn-delete {
background-color: #e74c3c;
color: white;
}
.btn-delete:hover {
background-color: #c0392b;
}
.form-control, .form-select {
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
}
.tag-closed {
background-color: #e0e0e0;
color: #666;
text-decoration: line-through;
}
[data-bs-theme="dark"] .tag-closed {
background-color: #3a3a3a;
color: #999;
}
.tag-state-badge {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
margin-left: 0.5rem;
font-weight: 600;
}
.tag-state-open {
background: #d4edda;
color: #155724;
}
.tag-state-closed {
background: #f8d7da;
color: #721c24;
}
[data-bs-theme="dark"] .tag-state-open {
background: #1e4620;
color: #7fd98d;
}
[data-bs-theme="dark"] .tag-state-closed {
background: #5c2b2f;
color: #f8a5ac;
}
.case-summary-card {
border: 1px solid rgba(0,0,0,0.06);
background: var(--bg-card);
box-shadow: 0 6px 18px rgba(15, 76, 117, 0.08);
}
.case-summary-header {
background: linear-gradient(135deg, rgba(15, 76, 117, 0.12), rgba(15, 76, 117, 0.02));
border-bottom: 1px solid rgba(0,0,0,0.06);
padding: 0.85rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.case-summary-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.25rem;
}
.case-summary-meta {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.case-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
background: rgba(15, 76, 117, 0.12);
color: var(--accent);
}
.case-pill-muted {
background: rgba(0,0,0,0.06);
color: var(--text-secondary);
}
.case-summary-body {
padding: 1rem;
}
.case-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem 1rem;
}
@media (max-width: 992px) {
.case-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 576px) {
.case-summary-grid {
grid-template-columns: 1fr;
}
}
.summary-item {
padding: 0.4rem 0.5rem;
border-radius: 10px;
background: rgba(0,0,0,0.02);
border: 1px solid rgba(0,0,0,0.04);
min-height: 44px;
}
.summary-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
margin-bottom: 0.15rem;
font-weight: 600;
}
.summary-value {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
word-break: break-word;
}
.summary-link {
color: var(--accent);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.case-summary-desc {
margin-top: 0.9rem;
padding: 0.8rem 0.9rem;
border-radius: 12px;
background: rgba(15, 76, 117, 0.05);
border: 1px dashed rgba(15, 76, 117, 0.2);
}
.defer-controls {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.35rem;
}
.defer-controls .btn {
padding: 0.15rem 0.45rem;
font-size: 0.72rem;
}
.summary-inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.summary-inline .summary-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
background: rgba(0,0,0,0.05);
color: var(--text-secondary);
}
.card[data-module].module-empty-compact {
height: auto !important;
min-height: 0;
--module-compact-min-height: 48px;
}
.card[data-module].module-empty-compact .card-body {
display: none;
}
.card[data-module].module-empty-compact .card-header,
.card[data-module].module-empty-compact .module-header {
margin-bottom: 0;
padding-top: 0.45rem;
padding-bottom: 0.45rem;
min-height: var(--module-compact-min-height);
}
.card[data-module].module-empty-compact .card-title,
.card[data-module].module-empty-compact h5,
.card[data-module].module-empty-compact h6 {
margin-bottom: 0;
font-size: 0.95rem;
}
.card[data-module].module-empty-compact .btn {
--bs-btn-padding-y: 0.2rem;
--bs-btn-padding-x: 0.45rem;
}
.todo-section-header {
padding: 0.28rem 0.55rem;
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
background: rgba(15, 76, 117, 0.06);
border-top: 1px solid rgba(15, 76, 117, 0.08);
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
}
.todo-step-item {
padding: 0.38rem 0.5rem;
}
.todo-step-header {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.todo-step-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.todo-step-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
text-align: right;
}
.todo-step-title {
font-weight: 600;
margin-bottom: 0;
font-size: 0.82rem;
line-height: 1.2;
}
.todo-step-meta {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
margin-top: 0.2rem;
}
.todo-step-meta .meta-pill {
display: inline-flex;
align-items: center;
padding: 0.08rem 0.34rem;
border-radius: 999px;
background: rgba(0,0,0,0.05);
color: var(--text-secondary);
font-size: 0.64rem;
}
.todo-step-actions {
display: flex;
gap: 0.25rem;
margin-top: 0.3rem;
}
.todo-step-right .todo-step-actions {
margin-top: 0;
}
.todo-info-btn {
width: 18px;
height: 18px;
padding: 0;
border-radius: 999px;
font-size: 0.65rem;
line-height: 1;
}
.todo-step-actions .btn {
--bs-btn-padding-y: 0.1rem;
--bs-btn-padding-x: 0.28rem;
--bs-btn-font-size: 0.68rem;
line-height: 1;
}
.relation-tree {
list-style: none;
margin: 0;
padding-left: 0;
}
/* Sub-trees need indentation */
.relation-children .relation-tree {
margin-left: 8px; /* Indent children */
}
.relation-node {
position: relative;
padding-left: 24px; /* Space for the connector */
}
/* Vertical Line (Spine) */
.relation-children {
/* This container wraps the child <ul> */
position: relative;
margin-left: 8px; /* Align with parent connector start */
border-left: 1px solid rgba(15, 76, 117, 0.2);
}
/* Horizontal Line (Connector) */
.relation-node:before {
content: "";
position: absolute;
left: 0;
top: 1.1rem; /* Mid-height of the top row (approx 32px/2 + padding) */
width: 20px;
height: 1px;
background: rgba(15, 76, 117, 0.2);
}
/* Fix: Last child should stop drawing the vertical line if we used ul border,
but here we use .relation-children border which covers all.
To get the "L" shape for the last child, we need the vertical line to come from the ITEM, not the LIST.
*/
/* Reset simpler approach: Stifinder style */
.relation-tree, .relation-children { border: none !important; margin: 0; padding: 0; }
.relation-node {
position: relative;
padding-left: 24px;
}
/* Vertical line up to this node */
.relation-node::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-left: 1px solid rgba(15, 76, 117, 0.25);
}
/* Horizontal link */
.relation-node::after {
content: '';
position: absolute;
top: 18px; /* Half of row height approx */
left: 0;
width: 20px;
height: 1px;
border-top: 1px solid rgba(15, 76, 117, 0.25);
}
/* Remove vertical line for the last item, but keep the top half to form "L" */
.relation-node:last-child::before {
height: 18px; /* connectors height */
bottom: auto;
}
/* Root node shouldn't have lines if it's top level?
Our macro recursively renders, so top level nodes are also .relation-node.
We might need a wrapper. */
.relation-tree > .relation-node:first-child::before,
.relation-tree > .relation-node:first-child::after {
/* If it's the absolute root, maybe no lines? depends on if we show multiple roots */
}
.relation-children {
margin-left: 24px; /* Indent for next level */
}
.relation-node-card {
background: rgba(15, 76, 117, 0.03);
border: 1px solid rgba(15, 76, 117, 0.12);
transition: background 0.2s;
}
.relation-node-card:hover {
background: rgba(15, 76, 117, 0.08);
}
.relation-type-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--accent);
background: rgba(15, 76, 117, 0.1);
padding: 2px 6px;
border-radius: 4px;
margin-right: 8px;
font-weight: 500;
}
border-radius: 999px;
background: rgba(15, 76, 117, 0.12);
color: var(--accent);
font-weight: 600;
margin-right: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.right-modules-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.right-modules-grid .card-header {
padding: 0.35rem 0.6rem;
}
.right-modules-grid .card-header h6 {
font-size: 0.8rem;
}
.right-modules-grid .card-body {
padding: 0.4rem;
}
.contact-list-header,
.contact-row {
display: grid;
grid-template-columns: 1.2fr 0.9fr 1fr auto;
gap: 0.5rem;
align-items: center;
}
.contact-list-header {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6c757d;
padding: 0 0.25rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.contact-row {
padding: 0.35rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
cursor: pointer;
}
.contact-row:last-child {
border-bottom: none;
}
.contact-row .contact-name {
font-weight: 600;
}
.contact-row small {
color: #6c757d;
}
.hardware-list-header,
.hardware-row,
.location-list-header,
.location-row,
.customer-list-header,
.customer-row {
display: grid;
align-items: center;
gap: 0.5rem;
}
.hardware-list-header,
.location-list-header,
.customer-list-header {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6c757d;
padding: 0 0.25rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.hardware-row,
.location-row,
.customer-row {
padding: 0.35rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.hardware-row:last-child,
.location-row:last-child,
.customer-row:last-child {
border-bottom: none;
}
.hardware-list-header,
.hardware-row {
grid-template-columns: 1.3fr 1fr auto;
}
.location-list-header,
.location-row {
grid-template-columns: 1.3fr 1fr auto;
}
.customer-list-header,
.customer-row {
grid-template-columns: 1.2fr 0.9fr 1fr auto;
}
.tag-toggle-btn {
background: none;
border: 1px solid rgba(0,0,0,0.2);
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
margin-left: 0.5rem;
transition: all 0.2s;
}
.tag-toggle-btn:hover {
background: rgba(0,0,0,0.05);
}
.tag-toggle-open {
color: #28a745;
border-color: #28a745;
}
.tag-toggle-open:hover {
background: #28a745;
color: white;
}
.tag-toggle-closed {
color: #6c757d;
border-color: #6c757d;
}
.tag-toggle-closed:hover {
background: #6c757d;
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">
<!-- Top Bar: Back Link + Global Tags -->
<div class="d-flex justify-content-between align-items-start mb-2">
<a href="/sag" class="back-link">
<i class="bi bi-chevron-left"></i> Tilbage til sager
</a>
<!-- Global Tags Area -->
<div class="d-flex align-items-center p-2 rounded" style="background: rgba(0,0,0,0.02);">
<i class="bi bi-tags text-muted me-2 small"></i>
<div id="case-tags" class="d-flex flex-wrap justify-content-end gap-1 align-items-center">
<span class="spinner-border spinner-border-sm text-muted"></span>
</div>
<button class="btn btn-sm btn-link text-decoration-none ms-1 px-1 py-0 text-muted hover-primary"
onclick="window.showTagPicker('case', {{ case.id }}, () => window.renderEntityTags('case', {{ case.id }}, 'case-tags'))"
title="Tilføj tag">
<i class="bi bi-plus-lg"></i>
</button>
<button class="btn btn-sm btn-link text-decoration-none ms-1 px-1 py-0 text-muted hover-primary"
onclick="openModuleControlModal()"
title="Vis/skjul moduler">
<i class="bi bi-sliders"></i>
</button>
</div>
</div>
<!-- Quick Info Bar (Redesigned) -->
<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">
<div class="d-flex flex-wrap align-items-center column-gap-4 row-gap-2" style="font-size: 0.85rem;">
<div class="d-flex align-items-center">
<strong style="color: var(--accent); margin-right: 0.4rem;">ID:</strong>
<span>{{ case.id }}</span>
</div>
<div class="d-flex align-items-center ps-3 border-start">
<strong style="color: var(--accent); margin-right: 0.4rem;">Kunde:</strong>
{% if customer %}
<a href="/customers/{{ customer.id }}" style="color: inherit; text-decoration: underline;">
{{ customer.name }}
</a>
{% else %}
<span>Ingen kunde</span>
{% endif %}
</div>
<div class="d-flex align-items-center ps-3 border-start">
<strong style="color: var(--accent); margin-right: 0.4rem;">Kontakt:</strong>
{% if hovedkontakt %}
<span style="cursor: pointer; text-decoration: underline; color: var(--accent);"
onclick="showKontaktModal()"
title="Se kontaktinfo">
{{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }}
</span>
{% else %}
<span class="text-muted fst-italic">Ingen</span>
{% endif %}
</div>
<div class="d-flex align-items-center ps-3 border-start">
<strong style="color: var(--accent); margin-right: 0.4rem;">Afdeling:</strong>
<span style="cursor: pointer; text-decoration: underline; color: var(--accent);"
onclick="showAfdelingModal()"
title="Ændre afdeling">
{{ customer.department if customer and customer.department else 'N/A' }}
</span>
</div>
<div class="d-flex align-items-center ps-3 border-start">
<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>
<!-- 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 '-' }}
</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>
</div>
<!-- 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>
</div>
</div>
</div>
</div>
<!-- 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>
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4" id="caseTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="details-tab" data-bs-toggle="tab" data-bs-target="#details" type="button" role="tab">
<i class="bi bi-card-text me-2"></i>Sagsdetaljer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="solution-tab" data-bs-toggle="tab" data-bs-target="#solution" type="button" role="tab" data-module-tab="solution">
<i class="bi bi-lightbulb me-2"></i>Løsning
{% if solution %}
<span class="badge bg-success ms-1 rounded-pill"><i class="bi bi-check"></i></span>
{% endif %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sales-tab" data-bs-toggle="tab" data-bs-target="#sales" type="button" role="tab" data-module-tab="sales">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="subscription-tab" data-bs-toggle="tab" data-bs-target="#subscription" type="button" role="tab" data-module-tab="subscription">
<i class="bi bi-repeat me-2"></i>Abonnement
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="reminders-tab" data-bs-toggle="tab" data-bs-target="#reminders" type="button" role="tab" data-module-tab="reminders">
<i class="bi bi-bell me-2"></i>Påmindelser
</button>
</li>
</ul>
<div class="tab-content" id="caseTabsContent">
<!-- Tab: Sagsdetaljer (Existing Content) -->
<div class="tab-pane fade show active" id="details" role="tabpanel" tabindex="0">
<div class="row g-4">
<div class="col-lg-8" id="case-left-column">
<!-- ROW 1: Main Info -->
<div class="row mb-3">
<!-- Main Case Info -->
<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">
<div>
<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>
<span class="case-pill case-pill-muted">{{ case.template_key or case.type or 'ticket' }}</span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<a href="/sag/{{ case.id }}/edit" class="btn btn-sm btn-outline-primary">
<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>
<div class="card-body case-summary-body">
<!-- 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>
{% else %}
<span class="text-muted">Ikke sat</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="summary-item h-100">
<div class="summary-label">Sandsynlighed</div>
<div class="summary-value">{{ case.pipeline_probability if case.pipeline_probability is not none else 0 }}%</div>
</div>
</div>
<div class="col-md-4">
<div class="summary-item h-100">
<div class="summary-label">Beløb</div>
<div class="summary-value">
{% if case.pipeline_amount is not none %}
{{ "{:,.2f}".format(case.pipeline_amount|float).replace(',', 'X').replace('.', ',').replace('X', '.') }} kr.
{% else %}
<span class="text-muted">Ikke sat</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="mt-3">
<div class="summary-item">
<div class="summary-label">Beskrivelse</div>
<div class="summary-value" style="white-space: pre-wrap;">{{ case.pipeline_description or 'Ingen beskrivelse' }}</div>
</div>
</div>
</div>
<div id="pipelineEditMode" class="d-none">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small text-muted">Stage</label>
<select id="pipelineStageSelect" class="form-select form-select-sm">
<option value="">Ikke sat</option>
{% for stage in pipeline_stages or [] %}
<option value="{{ stage.id }}" {% if case.pipeline_stage_id == stage.id %}selected{% endif %}>{{ stage.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Sandsynlighed (%)</label>
<input id="pipelineProbabilityInput" type="number" min="0" max="100" class="form-control form-control-sm" value="{{ case.pipeline_probability if case.pipeline_probability is not none else '' }}">
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Beløb (kr.)</label>
<input id="pipelineAmountInput" type="number" min="0" step="0.01" class="form-control form-control-sm" value="{{ case.pipeline_amount if case.pipeline_amount is not none else '' }}">
</div>
<div class="col-12">
<label class="form-label small text-muted">Beskrivelse</label>
<textarea id="pipelineDescriptionInput" rows="3" class="form-control form-control-sm" placeholder="Skriv kort note for denne pipeline-entry...">{{ case.pipeline_description or '' }}</textarea>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-3">
<button class="btn btn-sm btn-outline-secondary" onclick="togglePipelineEdit(false)">Annuller</button>
<button class="btn btn-sm btn-primary" onclick="savePipeline()">Gem pipeline</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ROW 2: Relations -->
<div class="row mb-3">
<div class="col-12 mb-3">
<div class="card h-100 d-flex flex-column" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<h6 class="mb-0" style="color: var(--accent);">🔗 Relationer</h6>
<i class="bi bi-info-circle text-muted"
style="font-size:0.9rem; cursor:help;"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="right"
title="<strong>Hvad betyder relationstyper?</strong><br><br><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.<br><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.<br><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.<br><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden."></i>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
<i class="bi bi-link-45deg"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="showCreateRelatedModal()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
{% macro render_tree(nodes) %}
<ul class="relation-tree">
{% for node in nodes %}
<li class="relation-node">
<div class="d-flex align-items-center py-1">
<div class="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 -->
{% if node.relation_type %}
{% set rel_icon = 'bi-link-45deg' %}
{% set rel_color = 'text-muted' %}
{% set rel_help = 'Faglig kobling uden direkte afhængighed' %}
{% if node.relation_type == 'Afledt af' %}
{% set rel_icon = 'bi-arrow-return-right' %}
{% set rel_color = 'text-info' %}
{% set rel_help = 'Denne sag er opstået på baggrund af en anden sag' %}
{% elif node.relation_type == 'Årsag til' %}
{% set rel_icon = 'bi-arrow-right-circle' %}
{% set rel_color = 'text-primary' %}
{% set rel_help = 'Denne sag er årsag til en anden sag' %}
{% elif node.relation_type == 'Blokkerer' %}
{% set rel_icon = 'bi-slash-circle' %}
{% set rel_color = 'text-danger' %}
{% set rel_help = 'Arbejdet i denne sag blokerer den anden' %}
{% endif %}
<span class="relation-type-badge {{ rel_color }}" title="{{ node.relation_type }}: {{ rel_help }}">
<i class="bi {{ rel_icon }}"></i>
<span class="d-none d-md-inline ms-1" style="font-size: 0.7rem;">{{ node.relation_type }}</span>
</span>
{% endif %}
<!-- Case Link -->
<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>
</a>
<!-- Status Dot -->
<span class="status-dot status-{{ node.case.status }} ms-2" title="{{ node.case.status }}"></span>
<!-- Duplicate/Reference Indicator -->
{% if node.is_repeated %}
<span class="text-muted ms-2" title="Denne sag vises også et andet sted i træet"><i class="bi bi-arrow-repeat"></i></span>
{% endif %}
<!-- Actions -->
{% if node.relation_id %}
<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>
{% endif %}
</div>
</div>
{% if node.children %}
<div class="relation-children">
{{ render_tree(node.children) }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% if relation_tree %}
<div class="relation-tree-container">
{{ render_tree(relation_tree) }}
</div>
{% else %}
<p class="text-muted text-center pt-3">Ingen relaterede sager</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 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">
<h6 class="mb-0" style="color: var(--accent);">📞 Opkaldshistorik</h6>
<a href="/telefoni" class="btn btn-sm btn-outline-primary">
<i class="bi bi-telephone"></i>
</a>
</div>
<div class="card-body p-0">
{% if call_history and call_history|length > 0 %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead>
<tr>
<th class="ps-3">Dato</th>
<th>Retning</th>
<th>Nummer</th>
<th>Bruger</th>
<th class="text-end pe-3">Varighed</th>
</tr>
</thead>
<tbody>
{% for call in call_history %}
<tr>
<td class="ps-3">{{ call.started_at.strftime('%d/%m/%Y %H:%M') if call.started_at else '-' }}</td>
<td>{{ 'Udgående' if call.direction == 'outbound' else 'Indgående' }}</td>
<td>
{% if call.ekstern_nummer %}
<div class="d-flex gap-2 align-items-center flex-wrap">
<span>{{ call.ekstern_nummer }}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="ringOutFromCase('{{ call.ekstern_nummer }}')">
Ring op
</button>
</div>
{% else %}
-
{% endif %}
</td>
<td>{{ call.full_name or call.username or '-' }}</td>
<td class="text-end pe-3">
{% if call.duration_sec is not none %}
{{ (call.duration_sec // 60)|int }}:{{ '%02d'|format((call.duration_sec % 60)|int) }}
{% elif call.ended_at %}
-
{% else %}
I gang
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted text-center">Ingen opkald linket til denne sag</div>
{% endif %}
</div>
</div>
</div>
</div>
<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>
<!-- ROW 3: Files + Linked Emails -->
<div class="row mb-3">
<!-- Files -->
<div class="col-md-6 mb-3">
<div class="card h-100" data-module="files" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📁 Filer & Dokumenter</h6>
<input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileUpload(this.files)">
<button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('fileInput').click()">
<i class="bi bi-cloud-upload"></i> Upload
</button>
</div>
<!-- Drag & Drop Zone -->
<div class="card-body p-0 d-flex flex-column" id="fileDropZone">
<div class="p-4 text-center border-bottom bg-light" id="fileDropMessage" style="cursor: pointer;" onclick="document.getElementById('fileInput').click()">
<i class="bi bi-cloud-arrow-up display-6 text-muted"></i>
<p class="small text-muted mb-0 mt-2">Træk filer hertil for at uploade</p>
</div>
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="files-list" style="max-height: 300px;">
<div class="p-3 text-center text-muted">Ingen filer fundet...</div>
</div>
</div>
</div>
</div>
<!-- Linked Emails -->
<div class="col-md-6 mb-3">
<div class="card h-100" data-module="emails" data-has-content="unknown">
<div class="card-header">
<h6 class="mb-0" style="color: var(--accent);">📧 Linkede e-mails</h6>
</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">
<input type="text" class="form-control form-control-sm" id="emailSearchInput" placeholder="Søg og link e-mail..." autocomplete="off">
<div class="list-group position-absolute shadow-sm" id="emailSearchResults" style="z-index: 1000; display: none; top: 100%; left: 0; right: 0; max-height: 300px; overflow-y: auto;"></div>
</div>
<div class="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;">
<div class="p-3 text-center text-muted">Ingen e-mails linket...</div>
</div>
</div>
</div>
</div>
</div>
<!-- File Preview Modal -->
<div class="modal fade" id="filePreviewModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-file-earmark-text me-2"></i>
<span id="previewFileName">Fil preview</span>
</h5>
<div class="ms-auto d-flex align-items-center gap-2">
<a id="previewDownloadBtn" href="#" class="btn btn-sm btn-outline-primary" download>
<i class="bi bi-download me-1"></i> Download
</a>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body p-0" style="min-height: 60vh; max-height: 80vh; overflow: hidden;">
<div id="previewContent" class="w-100 h-100 d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Search Modals -->
<div class="modal fade" id="contactSearchModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Søg kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="text" id="contactSearch" placeholder="Søg efter kontakt..." class="form-control mb-3">
<div id="contactSearchResults" style="max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="customerSearchModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Søg kunde</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="text" id="customerSearch" placeholder="Søg efter kunde..." class="form-control mb-3">
<div id="customerSearchResults" style="max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="relationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🔗 Tilføj relation til sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-bold">1. Søg og vælg sag</label>
<input type="text"
id="relationCaseSearch"
placeholder="Søg efter sag ID, titel, kunde eller beskrivelse..."
class="form-control form-control-lg"
autocomplete="off">
<div id="relationSearchResults"
style="max-height: 400px; overflow-y: auto; margin-top: 0.5rem;"
class="border rounded"></div>
</div>
<div id="selectedCasePreview" style="display: none;" class="alert alert-info mb-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>Valgt sag:</strong>
<div id="selectedCaseTitle" class="mt-1"></div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSelectedRelationCase()">
Ryd valg
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">2. Vælg relationstype</label>
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
<option value="">Vælg hvordan sagerne er relateret...</option>
<option value="Relateret til">🔗 Relateret til - Faglig kobling uden direkte afhængighed</option>
<option value="Afledt af">↪ Afledt af - Denne sag er opstået på baggrund af den anden</option>
<option value="Årsag til">➡ Årsag til - Denne sag er årsagen til den anden</option>
<option value="Blokkerer">⛔ Blokkerer - Denne sag stopper fremdrift i den anden</option>
</select>
</div>
<div id="relationTypeHint" class="alert alert-info small mb-3" style="display:none;"></div>
<div class="alert alert-light border small mb-3">
<div class="fw-semibold mb-1">Betydning i praksis</div>
<div><strong>Relateret til</strong>: Bruges når sager hænger sammen, men ingen af dem afhænger direkte af den anden.</div>
<div><strong>Afledt af</strong>: Bruges når denne sag er afledt af et tidligere problem/arbejde.</div>
<div><strong>Årsag til</strong>: Bruges når denne sag skaber behovet for den anden.</div>
<div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div>
</div>
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
<i class="bi bi-info-circle me-2"></i>
<div>
<strong>Tip:</strong> Brug pile (↑↓) til at navigere i søgeresultater, Enter til at vælge.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button"
class="btn btn-primary btn-lg"
onclick="addRelation()"
id="addRelationBtn"
disabled>
<i class="bi bi-plus-circle me-1"></i> Tilføj relation
</button>
</div>
</div>
</div>
</div>
<!-- Contact Info Modal -->
<div class="modal fade" id="contactInfoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="contactInfoName">Kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<div class="text-muted small">Titel</div>
<div id="contactInfoTitle">-</div>
</div>
<div class="mb-2">
<div class="text-muted small">Kunde</div>
<div id="contactInfoCompany">-</div>
</div>
<div class="mb-2">
<div class="text-muted small">E-mail</div>
<div id="contactInfoEmail">-</div>
</div>
<div class="mb-2">
<div class="text-muted small">Telefon</div>
<div id="contactInfoPhone">-</div>
</div>
<div class="mb-2">
<div class="text-muted small">Mobil</div>
<div id="contactInfoMobile">-</div>
</div>
<div class="mb-2">
<div class="text-muted small">Rolle</div>
<div id="contactInfoRole">-</div>
</div>
<div id="contactInfoPrimary" class="badge bg-primary d-none">Hovedkontakt</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="openContactRoleFromInfo()">Rediger rolle</button>
</div>
</div>
</div>
</div>
<!-- Contact Role Modal -->
<div class="modal fade" id="contactRoleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger kontaktrolle</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="contactRoleContactId" />
<div class="mb-2">
<label class="form-label">Kontakt</label>
<div id="contactRoleName" class="fw-semibold">-</div>
</div>
<div class="mb-3">
<label class="form-label">Rolle</label>
<input type="text" class="form-control" id="contactRoleInput" placeholder="fx ansvarlig, beslutningstager">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="contactRolePrimary">
<label class="form-check-label" for="contactRolePrimary">Sæt som hovedkontakt</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveContactRole()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Deadline Modal -->
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deadline</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">Dato</label>
<input
type="date"
class="form-control form-control-sm"
id="deadlineInput"
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
/>
<div class="defer-controls mt-2">
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(1)">+1 dag</button>
<button class="btn btn-outline-primary" onclick="shiftDeadlineDays(7)">+1 uge</button>
<button class="btn btn-outline-primary" onclick="shiftDeadlineMonths(1)">+1 mnd</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-outline-danger" onclick="clearDeadlineAll()">Ryd</button>
<button type="button" class="btn btn-primary" onclick="saveDeadlineAll()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Deferred Modal -->
<div class="modal fade" id="deferredModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Udsat start</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label">Dato</label>
<input
type="date"
class="form-control form-control-sm"
id="deferredUntilInput"
value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}"
/>
<div class="defer-controls mt-2">
<button class="btn btn-outline-primary" onclick="shiftDeferredDays(1)">+1 dag</button>
<button class="btn btn-outline-primary" onclick="shiftDeferredDays(7)">+1 uge</button>
<button class="btn btn-outline-primary" onclick="shiftDeferredMonths(1)">+1 mnd</button>
</div>
<label class="form-label mt-3">Udsat til sagstatus</label>
<select class="form-select form-select-sm" id="deferredCaseSelect">
<option value="">Vælg relateret sag</option>
{% for rc in related_case_options %}
<option value="{{ rc.id }}" {% if case.deferred_until_case_id == rc.id %}selected{% endif %}>
#{{ rc.id }} {{ rc.titel }}
</option>
{% endfor %}
</select>
<select class="form-select form-select-sm mt-2" id="deferredStatusSelect">
<option value="">Vælg status</option>
{% for st in status_options %}
<option value="{{ st }}" {% if case.deferred_until_status == st %}selected{% endif %}>
{{ st }}
</option>
{% endfor %}
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-outline-danger" onclick="clearDeferredAll()">Ryd</button>
<button type="button" class="btn btn-primary" onclick="saveDeferredAll()">Gem</button>
</div>
</div>
</div>
</div>
<!-- Module Control Modal -->
<div class="modal fade" id="moduleControlModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Vis/skjul moduler</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="moduleControlList">
<div class="text-muted small">Indlæser...</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<script>
const caseId = {{ case.id }};
const wikiCustomerId = {{ customer.id if customer else 'null' }};
const wikiDefaultTag = "guide";
let contactSearchTimeout;
let customerSearchTimeout;
let relationSearchTimeout;
let wikiSearchTimeout;
let selectedRelationCaseId = null;
const caseTypeKey = "{{ (case.template_key or case.type or 'ticket')|lower }}";
window.moduleDisplayNames = {
'relations': 'Relationer',
'call-history': 'Opkaldshistorik',
'files': 'Filer',
'emails': 'E-mails',
'pipeline': 'Salgspipeline',
'hardware': 'Hardware',
'locations': 'Lokationer',
'contacts': 'Kontakter',
'customers': 'Kunder',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
'solution': 'Løsning',
'sales': 'Varekøb & salg',
'subscription': 'Abonnement',
'reminders': 'Påmindelser',
'calendar': 'Kalender'
};
let caseTypeModuleDefaults = {};
// Modal instances
let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance;
let currentContactInfo = null;
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize modals
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
contactInfoModal = new bootstrap.Modal(document.getElementById('contactInfoModal'));
createRelatedCaseModalInstance = new bootstrap.Modal(document.getElementById('createRelatedCaseModal'));
// Setup search handlers
setupContactSearch();
setupCustomerSearch();
setupRelationSearch();
updateRelationTypeHint();
updateNewCaseRelationTypeHint();
// Initialize all tooltips on the page
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
bootstrap.Tooltip.getOrCreateInstance(el, { html: true, container: 'body' });
});
// Render Global Tags
if (window.renderEntityTags) {
window.renderEntityTags('case', {{ case.id }}, 'case-tags');
}
Promise.all([loadModulePrefs(), loadCaseTypeModuleDefaultsSetting()]).then(() => applyViewFromTags());
// Set default context for keyboard shortcuts (Option+Shift+T)
if (window.setTagPickerContext) {
window.setTagPickerContext('case', {{ case.id }}, () => window.renderEntityTags('case', {{ case.id }}, 'case-tags'));
}
// Load Hardware & Locations
loadCaseHardware();
loadCaseLocations();
loadCaseWiki();
loadTodoSteps();
const wikiSearchInput = document.getElementById('wikiSearchInput');
if (wikiSearchInput) {
wikiSearchInput.addEventListener('input', () => {
clearTimeout(wikiSearchTimeout);
wikiSearchTimeout = setTimeout(() => {
loadCaseWiki(wikiSearchInput.value || '');
}, 300);
});
}
const todoForm = document.getElementById('todoStepForm');
if (todoForm) {
todoForm.addEventListener('submit', createTodoStep);
}
// Focus on title when create modal opens
const createModalEl = document.getElementById('createRelatedCaseModal');
if (createModalEl) {
createModalEl.addEventListener('shown.bs.modal', function () {
document.getElementById('newCaseTitle').focus();
});
}
});
// Show modal functions
function showContactSearch() {
contactSearchModal.show();
setTimeout(() => document.getElementById('contactSearch').focus(), 300);
}
function showCustomerSearch() {
customerSearchModal.show();
setTimeout(() => document.getElementById('customerSearch').focus(), 300);
}
function showRelationModal() {
relationModal.show();
updateRelationTypeHint();
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
}
function showContactInfoModal(el) {
currentContactInfo = {
id: el.dataset.contactId,
name: el.dataset.name || '-',
title: el.dataset.title || '-',
company: el.dataset.company || '-',
email: el.dataset.email || '-',
phone: el.dataset.phone || '-',
mobile: el.dataset.mobile || '-',
role: el.dataset.role || '-',
isPrimary: el.dataset.isPrimary === 'true'
};
document.getElementById('contactInfoName').textContent = currentContactInfo.name;
document.getElementById('contactInfoTitle').textContent = currentContactInfo.title || '-';
document.getElementById('contactInfoCompany').textContent = currentContactInfo.company || '-';
document.getElementById('contactInfoEmail').textContent = currentContactInfo.email || '-';
document.getElementById('contactInfoPhone').innerHTML = renderCasePhone(currentContactInfo.phone);
document.getElementById('contactInfoMobile').innerHTML = renderCaseMobile(currentContactInfo.mobile, currentContactInfo.name);
document.getElementById('contactInfoRole').textContent = currentContactInfo.role || '-';
const primaryBadge = document.getElementById('contactInfoPrimary');
if (currentContactInfo.isPrimary) {
primaryBadge.classList.remove('d-none');
} else {
primaryBadge.classList.add('d-none');
}
contactInfoModal.show();
}
function renderCasePhone(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') return '-';
return `<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>`;
}
function renderCaseMobile(number, name) {
const clean = String(number || '').trim();
if (!clean || clean === '-') return '-';
return `
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '${escapeHtml(name || '')}', ${currentContactInfo?.id || 'null'})">SMS</button>
</div>
`;
}
function openContactRoleFromInfo() {
if (!currentContactInfo) return;
contactInfoModal.hide();
openContactRoleModal(
currentContactInfo.id,
currentContactInfo.name,
currentContactInfo.role || 'Kontakt',
currentContactInfo.isPrimary
);
}
function showCreateRelatedModal() {
createRelatedCaseModalInstance.show();
updateNewCaseRelationTypeHint();
}
function relationTypeMeaning(type) {
const map = {
'Relateret til': {
icon: '🔗',
text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
},
'Afledt af': {
icon: '↪',
text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
},
'Årsag til': {
icon: '➡',
text: 'Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).'
},
'Blokkerer': {
icon: '⛔',
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
}
};
return map[type] || null;
}
function updateRelationTypeHint() {
const select = document.getElementById('relationTypeSelect');
const hint = document.getElementById('relationTypeHint');
if (!select || !hint) return;
const meaning = relationTypeMeaning(select.value);
if (!meaning) {
hint.style.display = 'none';
hint.innerHTML = '';
return;
}
hint.style.display = 'block';
hint.innerHTML = `<strong>${meaning.icon} Betydning:</strong> ${meaning.text}`;
}
function updateNewCaseRelationTypeHint() {
const select = document.getElementById('newCaseRelationType');
const hint = document.getElementById('newCaseRelationTypeHint');
if (!select || !hint) return;
const selected = select.value;
if (selected === 'Afledt af') {
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som afledt af den nye sag.';
return;
}
if (selected === 'Årsag til') {
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som årsag til den nye sag.';
return;
}
if (selected === 'Blokkerer') {
hint.innerHTML = '<strong>⛔ Effekt:</strong> Nuværende sag markeres som blokering for den nye sag.';
return;
}
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed.';
}
async function createRelatedCase() {
const title = document.getElementById('newCaseTitle').value;
const relationType = document.getElementById('newCaseRelationType').value;
const description = document.getElementById('newCaseDescription').value;
if (!title) {
alert('Titel er påkrævet');
return;
}
// 1. Create the new case
try {
const caseResponse = await fetch('/api/v1/sag', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
titel: title,
beskrivelse: description,
customer_id: {{ case.customer_id }},
status: 'åben'
})
});
if (!caseResponse.ok) throw new Error('Kunne ikke oprette sag');
const newCase = await caseResponse.json();
// 2. Create the relation
const relationResponse = await fetch(`/api/v1/sag/${caseId}/relationer`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
målsag_id: newCase.id,
relationstype: relationType
})
});
if (!relationResponse.ok) throw new Error('Kunne ikke oprette relation');
// 3. Reload to show new relation
window.location.reload();
} catch (err) {
console.error('Error creating related case:', err);
alert('Der opstod en fejl: ' + err.message);
}
}
function confirmDeleteCase() {
if(confirm('Slet denne sag?')) {
fetch('/api/v1/sag/{{ case.id }}', {method: 'DELETE'})
.then(() => window.location='/sag');
}
}
// Contact Search
function setupContactSearch() {
const contactSearchInput = document.getElementById('contactSearch');
contactSearchInput.addEventListener('input', function(e) {
clearTimeout(contactSearchTimeout);
const query = e.target.value.trim();
if (query.length < 2) {
document.getElementById('contactSearchResults').innerHTML = '';
return;
}
contactSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`);
const contacts = await response.json();
const resultsDiv = document.getElementById('contactSearchResults');
if (contacts.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
} else {
resultsDiv.innerHTML = contacts.map(c => `
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
onclick="addContact(${caseId}, ${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
<strong>${c.first_name} ${c.last_name}</strong>
<div class="small text-muted">${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}</div>
</div>
`).join('');
}
} catch (err) {
console.error('Error searching contacts:', err);
}
}, 300);
});
}
async function addContact(caseId, contactId, contactName) {
try {
const response = await fetch(`/api/v1/sag/${caseId}/contacts`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: contactId, role: 'Kontakt'})
});
if (response.ok) {
contactSearchModal.hide();
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
}
} catch (err) {
alert('Fejl ved tilføjelse af kontakt: ' + err.message);
}
}
async function removeContact(caseId, contactId) {
if (confirm('Fjern denne kontakt fra sagen?')) {
const response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, {method: 'DELETE'});
if (response.ok) {
window.location.reload();
} else {
alert('Fejl ved fjernelse af kontakt');
}
}
}
// Customer Search
function setupCustomerSearch() {
const customerSearchInput = document.getElementById('customerSearch');
customerSearchInput.addEventListener('input', function(e) {
clearTimeout(customerSearchTimeout);
const query = e.target.value.trim();
if (query.length < 2) {
document.getElementById('customerSearchResults').innerHTML = '';
return;
}
customerSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
const customers = await response.json();
const resultsDiv = document.getElementById('customerSearchResults');
if (customers.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kunder fundet</div>';
} else {
resultsDiv.innerHTML = customers.map(c => `
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
onclick="addCustomer(${caseId}, ${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<strong>${c.name}</strong>
<div class="small text-muted">${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}</div>
</div>
`).join('');
}
} catch (err) {
console.error('Error searching customers:', err);
}
}, 300);
});
}
async function addCustomer(caseId, customerId, customerName) {
try {
const response = await fetch(`/api/v1/sag/${caseId}/customers`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({customer_id: customerId, role: 'Kunde'})
});
if (response.ok) {
customerSearchModal.hide();
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
}
} catch (err) {
alert('Fejl ved tilføjelse af kunde: ' + err.message);
}
}
async function removeCustomer(caseId, customerId) {
if (confirm('Fjern denne kunde fra sagen?')) {
const response = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, {method: 'DELETE'});
if (response.ok) {
window.location.reload();
} else {
alert('Fejl ved fjernelse af kunde');
}
}
}
// Relation Search - Enhanced version
let currentFocusIndex = -1;
let searchResults = [];
function setupRelationSearch() {
const relationSearchInput = document.getElementById('relationCaseSearch');
// Input handler
relationSearchInput.addEventListener('input', function(e) {
clearTimeout(relationSearchTimeout);
const query = e.target.value.trim();
currentFocusIndex = -1;
if (query.length < 2) {
document.getElementById('relationSearchResults').innerHTML = '';
document.getElementById('relationSearchResults').style.display = 'none';
return;
}
relationSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query)}`);
const cases = await response.json();
searchResults = cases.filter(c => c.id !== caseId);
renderRelationSearchResults(searchResults);
} catch (err) {
console.error('Error searching cases:', err);
}
}, 200);
});
// Keyboard navigation
relationSearchInput.addEventListener('keydown', function(e) {
const resultsDiv = document.getElementById('relationSearchResults');
const items = resultsDiv.querySelectorAll('.relation-search-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
currentFocusIndex = (currentFocusIndex + 1) % items.length;
updateFocusedItem(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
currentFocusIndex = currentFocusIndex <= 0 ? items.length - 1 : currentFocusIndex - 1;
updateFocusedItem(items);
} else if (e.key === 'Enter') {
e.preventDefault();
if (currentFocusIndex >= 0 && currentFocusIndex < items.length) {
items[currentFocusIndex].click();
}
}
});
}
function updateFocusedItem(items) {
items.forEach((item, index) => {
if (index === currentFocusIndex) {
item.classList.add('active');
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else {
item.classList.remove('active');
}
});
}
function renderRelationSearchResults(cases) {
const resultsDiv = document.getElementById('relationSearchResults');
if (cases.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted text-center"><i class="bi bi-search me-2"></i>Ingen sager fundet</div>';
resultsDiv.style.display = 'block';
return;
}
// Group by status
const grouped = {};
cases.forEach(c => {
const status = c.status || 'ukendt';
if (!grouped[status]) grouped[status] = [];
grouped[status].push(c);
});
let html = '<div class="list-group list-group-flush">';
// Sort status groups: åben first, then others
const statusOrder = ['åben', 'under behandling', 'afventer', 'løst', 'lukket'];
const sortedStatuses = Object.keys(grouped).sort((a, b) => {
const aIndex = statusOrder.indexOf(a);
const bIndex = statusOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
sortedStatuses.forEach(status => {
const statusCases = grouped[status];
// Status group header
html += `
<div class="list-group-item bg-light" style="padding: 0.5rem 1rem; font-weight: 600; font-size: 0.85rem; color: var(--text-secondary);">
<span class="status-badge status-${status}">${status}</span>
<span class="badge bg-secondary float-end">${statusCases.length}</span>
</div>
`;
statusCases.forEach(c => {
const createdDate = c.created_at ? new Date(c.created_at).toLocaleDateString('da-DK') : 'N/A';
const beskrivelse = c.beskrivelse ? c.beskrivelse.substring(0, 80) + '...' : '';
const customerName = c.customer_name || '';
const safeTitle = (c.titel || '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const safeCustomer = customerName.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
html += `
<div class="list-group-item list-group-item-action relation-search-item"
style="cursor: pointer; padding: 0.75rem 1rem;"
onclick="selectRelationCase(${c.id}, '${safeTitle}', '${safeCustomer}', '${status}');"
data-case-id="${c.id}">
<div class="d-flex justify-content-between align-items-start">
<div style="flex: 1;">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-primary" style="font-size: 0.75rem;">#${c.id}</span>
<strong style="font-size: 0.95rem;">${escapeHtml(c.titel)}</strong>
</div>
${c.customer_name ? `
<div class="small text-muted mb-1">
<i class="bi bi-building me-1"></i>${escapeHtml(c.customer_name)}
</div>
` : ''}
${beskrivelse ? `
<div class="small text-muted" style="font-size: 0.8rem;">${escapeHtml(beskrivelse)}</div>
` : ''}
</div>
<div class="text-end" style="min-width: 100px;">
<div class="small text-muted">${createdDate}</div>
</div>
</div>
</div>
`;
});
});
html += '</div>';
resultsDiv.innerHTML = html;
resultsDiv.style.display = 'block';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function selectRelationCase(caseIdValue, caseTitel, customerName, status) {
selectedRelationCaseId = caseIdValue;
// Update preview
const previewDiv = document.getElementById('selectedCasePreview');
const titleDiv = document.getElementById('selectedCaseTitle');
titleDiv.innerHTML = `
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-primary">#${caseIdValue}</span>
<strong>${escapeHtml(caseTitel)}</strong>
<span class="status-badge status-${status}">${status}</span>
</div>
${customerName ? `<div class="small"><i class="bi bi-building me-1"></i>${escapeHtml(customerName)}</div>` : ''}
`;
previewDiv.style.display = 'block';
document.getElementById('relationSearchResults').innerHTML = '';
document.getElementById('relationSearchResults').style.display = 'none';
document.getElementById('relationCaseSearch').value = '';
// Enable add button
updateAddRelationButton();
}
function clearSelectedRelationCase() {
selectedRelationCaseId = null;
document.getElementById('selectedCasePreview').style.display = 'none';
document.getElementById('relationCaseSearch').value = '';
document.getElementById('relationCaseSearch').focus();
updateAddRelationButton();
}
function updateAddRelationButton() {
const btn = document.getElementById('addRelationBtn');
const relationType = document.getElementById('relationTypeSelect').value;
btn.disabled = !selectedRelationCaseId || !relationType;
}
async function addRelation() {
const relationType = document.getElementById('relationTypeSelect').value;
const btn = document.getElementById('addRelationBtn');
if (!selectedRelationCaseId) {
alert('Vælg en sag først');
return;
}
if (!relationType) {
alert('Vælg en relationstype');
return;
}
// Disable button during request
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Tilføjer...';
try {
const response = await fetch(`/api/v1/sag/${caseId}/relationer`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
målsag_id: selectedRelationCaseId,
relationstype: relationType
})
});
if (response.ok) {
selectedRelationCaseId = null;
relationModal.hide();
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
}
} catch (err) {
alert('Fejl ved tilføjelse af relation: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
}
}
async function deleteRelation(relationId) {
if (confirm('Fjern denne relation?')) {
const response = await fetch(`/api/v1/sag/${caseId}/relationer/${relationId}`, {method: 'DELETE'});
if (response.ok) {
window.location.reload();
} else {
alert('Fejl ved fjernelse af relation');
}
}
}
// ============ Hardware Handling ============
async function loadCaseHardware() {
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`);
const hardware = await res.json();
const container = document.getElementById('hardware-list');
if (hardware.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen hardware tilknyttet</div>';
setModuleContentState('hardware', false);
return;
}
container.innerHTML = `
<div class="hardware-list-header">
<span>Enhed</span>
<span>SN</span>
<span>Slet</span>
</div>
${hardware.map(h => `
<div class="hardware-row">
<div>
<a href="/hardware/${h.id}" class="text-decoration-none fw-semibold">
${h.brand} ${h.model}
</a>
</div>
<small>${h.serial_number || '-'}</small>
<button class="btn btn-sm btn-delete" onclick="unlinkHardware(${h.id})" title="Slet">
</button>
</div>
`).join('')}
`;
setModuleContentState('hardware', true);
} catch (e) {
console.error("Error loading hardware:", e);
document.getElementById('hardware-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</div>';
setModuleContentState('hardware', true);
}
}
async function promptLinkHardware() {
const id = prompt("Indtast Hardware ID:");
if (!id) return;
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ hardware_id: parseInt(id) })
});
if (!res.ok) throw await res.json();
loadCaseHardware();
} catch (e) {
alert("Fejl: " + (e.detail || e.message));
}
}
async function unlinkHardware(hwId) {
if(!confirm("Fjern link til dette hardware?")) return;
try {
await fetch(`/api/v1/sag/{{ case.id }}/hardware/${hwId}`, { method: 'DELETE' });
loadCaseHardware();
} catch (e) {
alert("Fejl ved sletning");
}
}
// ============ Location Handling ============
async function loadCaseLocations() {
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`);
const locations = await res.json();
const container = document.getElementById('locations-list');
if (locations.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen lokationer tilknyttet</div>';
setModuleContentState('locations', false);
return;
}
container.innerHTML = `
<div class="location-list-header">
<span>Navn</span>
<span>Type</span>
<span>Slet</span>
</div>
${locations.map(l => `
<div class="location-row">
<div class="fw-semibold">
<i class="bi bi-geo-alt me-1 text-secondary"></i>
${l.name}
</div>
<small>${l.location_type || '-'}</small>
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.relation_id || l.id})" title="Slet">
</button>
</div>
`).join('')}
`;
setModuleContentState('locations', true);
} catch (e) {
console.error("Error loading locations:", e);
document.getElementById('locations-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</div>';
setModuleContentState('locations', true);
}
}
// ============ Wiki Handling ============
async function loadCaseWiki(searchValue = '') {
const container = document.getElementById('wiki-list');
if (!container) return;
if (!wikiCustomerId) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen kunde tilknyttet</div>';
setModuleContentState('wiki', false);
return;
}
container.innerHTML = '<div class="p-3 text-center text-muted small">Henter wiki...</div>';
const params = new URLSearchParams();
const trimmed = (searchValue || '').trim();
if (trimmed) {
params.set('query', trimmed);
} else {
params.set('tag', wikiDefaultTag);
}
try {
const res = await fetch(`/api/v1/wiki/customers/${wikiCustomerId}/pages?${params.toString()}`);
if (!res.ok) {
throw new Error('Kunne ikke hente Wiki');
}
const payload = await res.json();
if (payload.errors && payload.errors.length) {
container.innerHTML = '<div class="p-3 text-center text-danger small">Wiki API fejlede</div>';
setModuleContentState('wiki', true);
return;
}
const pages = Array.isArray(payload.pages) ? payload.pages : [];
if (!pages.length) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen sider fundet</div>';
setModuleContentState('wiki', false);
return;
}
container.innerHTML = pages.map(page => {
const title = page.title || page.path || 'Wiki side';
const url = page.url || page.path || '#';
const safeUrl = url ? encodeURI(url) : '#';
return `
<a class="list-group-item list-group-item-action" href="${safeUrl}" target="_blank" rel="noopener">
<div class="fw-semibold">${escapeHtml(title)}</div>
<small class="text-muted">${escapeHtml(page.path || '')}</small>
</a>
`;
}).join('');
setModuleContentState('wiki', true);
} catch (e) {
console.error('Error loading Wiki:', e);
container.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning</div>';
setModuleContentState('wiki', true);
}
}
let todoUserId = null;
function getTodoUserId() {
if (todoUserId) return todoUserId;
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
todoUserId = payload.sub || payload.user_id;
return todoUserId;
} catch (e) {
console.warn('Could not decode token for todo user_id');
}
}
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) {
todoUserId = metaTag.getAttribute('content');
}
return todoUserId;
}
function formatTodoDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleDateString('da-DK');
}
function formatTodoDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('da-DK', { hour: '2-digit', minute: '2-digit', hour12: false });
}
function renderTodoSteps(steps) {
const list = document.getElementById('todo-steps-list');
if (!list) return;
const escapeAttr = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (!steps || steps.length === 0) {
list.innerHTML = '<div class="p-3 text-center text-muted">Ingen opgaver endnu</div>';
setModuleContentState('todo-steps', false);
return;
}
const openSteps = steps.filter(step => !step.is_done);
const doneSteps = steps.filter(step => step.is_done);
const renderStep = (step) => {
const createdBy = step.created_by_name || 'Ukendt';
const completedBy = step.completed_by_name || 'Ukendt';
const dueLabel = step.due_date ? formatTodoDate(step.due_date) : '-';
const createdLabel = formatTodoDateTime(step.created_at);
const completedLabel = step.completed_at ? formatTodoDateTime(step.completed_at) : null;
const statusBadge = step.is_done
? '<span class="badge bg-success">Færdig</span>'
: '<span class="badge bg-warning text-dark">Åben</span>';
const toggleLabel = step.is_done ? 'Genåbn' : 'Færdig';
const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success';
const tooltipText = [
`Oprettet af: ${createdBy}`,
`Oprettet: ${createdLabel}`,
`Forfald: ${dueLabel}`,
step.is_done && completedLabel ? `Færdiggjort af: ${completedBy}` : null,
step.is_done && completedLabel ? `Færdiggjort: ${completedLabel}` : null
].filter(Boolean).join('<br>');
return `
<div class="list-group-item todo-step-item">
<div class="todo-step-title todo-step-header">
<div class="todo-step-left">
<span>${step.title}</span>
<button type="button" class="btn btn-outline-secondary todo-info-btn" data-bs-toggle="tooltip" data-bs-html="true" title="${escapeAttr(tooltipText)}" aria-label="Vis detaljer">
<i class="bi bi-info"></i>
</button>
</div>
<div class="todo-step-right">
${statusBadge}
<div class="todo-step-actions">
<button class="btn btn-sm ${toggleClass}" onclick="toggleTodoStep(${step.id}, ${step.is_done ? 'false' : 'true'})" title="${toggleLabel}" aria-label="${toggleLabel}">
<i class="bi ${step.is_done ? 'bi-arrow-counterclockwise' : 'bi-check2'}"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTodoStep(${step.id})" title="Slet" aria-label="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
${step.description ? `<div class="small text-muted">${step.description}</div>` : ''}
<div class="todo-step-meta">
<span class="meta-pill">Forfald: ${dueLabel}</span>
</div>
</div>
`;
};
const sections = [];
if (openSteps.length) {
sections.push(`
<div class="todo-section-header">Åbne (${openSteps.length})</div>
${openSteps.map(renderStep).join('')}
`);
}
if (doneSteps.length) {
sections.push(`
<div class="todo-section-header">Færdige (${doneSteps.length})</div>
${doneSteps.map(renderStep).join('')}
`);
}
list.innerHTML = sections.join('');
if (window.bootstrap) {
list.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
bootstrap.Tooltip.getOrCreateInstance(el, {
trigger: 'hover focus',
placement: 'left',
container: 'body',
html: true
});
});
}
setModuleContentState('todo-steps', true);
}
async function loadTodoSteps() {
const list = document.getElementById('todo-steps-list');
if (!list) return;
list.innerHTML = '<div class="p-3 text-center text-muted">Henter opgaver...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps`);
if (!res.ok) throw new Error('Kunne ikke hente steps');
const steps = await res.json();
renderTodoSteps(steps || []);
} catch (e) {
console.error('Error loading todo steps:', e);
list.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning</div>';
setModuleContentState('todo-steps', true);
}
}
function toggleTodoStepForm(forceOpen = null) {
const form = document.getElementById('todoStepForm');
const moduleCard = document.querySelector('[data-module="todo-steps"]');
if (!form) return;
const shouldOpen = forceOpen === null ? form.classList.contains('d-none') : Boolean(forceOpen);
if (shouldOpen) {
form.classList.remove('d-none');
if (moduleCard) {
moduleCard.classList.remove('module-empty-compact');
}
const titleInput = document.getElementById('todoStepTitle');
if (titleInput) {
titleInput.focus();
}
} else {
form.classList.add('d-none');
applyViewLayout(currentCaseView);
}
}
async function createTodoStep(event) {
event.preventDefault();
const titleInput = document.getElementById('todoStepTitle');
const descInput = document.getElementById('todoStepDescription');
const dueInput = document.getElementById('todoStepDueDate');
if (!titleInput) return;
const title = titleInput.value.trim();
if (!title) {
alert('Titel er paakraevet');
return;
}
const userId = getTodoUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
try {
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps?user_id=${userId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: descInput.value.trim() || null,
due_date: dueInput.value || null
})
}
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette step');
}
titleInput.value = '';
descInput.value = '';
dueInput.value = '';
await loadTodoSteps();
toggleTodoStepForm(false);
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function toggleTodoStep(stepId, isDone) {
const userId = getTodoUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
try {
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}?user_id=${userId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_done: isDone })
}
);
if (!res.ok) throw new Error('Kunne ikke opdatere step');
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function deleteTodoStep(stepId) {
if (!confirm('Slet dette step?')) return;
try {
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Kunne ikke slette step');
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function promptLinkLocation() {
const id = prompt("Indtast Lokations ID:");
if (!id) return;
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ location_id: parseInt(id) })
});
if (!res.ok) throw await res.json();
loadCaseLocations();
} catch (e) {
alert("Fejl: " + (e.detail || e.message));
}
}
async function unlinkLocation(locId) {
if(!confirm("Fjern link til denne lokation?")) return;
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Kunne ikke fjerne lokation');
}
loadCaseLocations();
} catch (e) {
alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
}
}
// Initialize relation search when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupRelationSearch);
} else {
setupRelationSearch();
}
// Kontakt Modal functions
function showKontaktModal() {
const modal = new bootstrap.Modal(document.getElementById('kontaktModal'));
modal.show();
}
// Afdeling Modal functions
function showAfdelingModal() {
const modal = new bootstrap.Modal(document.getElementById('afdelingModal'));
modal.show();
}
async function updateAfdeling() {
const newAfdeling = document.getElementById('afdelingInput').value.trim();
try {
const response = await fetch('/api/v1/customers/{{ customer.id if customer else 0 }}', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ department: newAfdeling })
});
if (!response.ok) throw await response.json();
// Reload page to show updated data
window.location.reload();
} catch (e) {
alert("Fejl ved opdatering: " + (e.detail || e.message));
}
}
</script>
<!-- Tid & Fakturering Section (Moved from Right Column) -->
<div class="card mt-3" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h5>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
<i class="bi bi-fullscreen me-1"></i>Fuld Formular
</button>
</div>
<div class="card-body">
<!-- Quick Add Time Entry Form -->
<div class="border rounded p-2 mb-2 bg-light" id="quickTimeFormContainer">
<form id="quickAddTimeForm" onsubmit="quickAddTime(event); return false;">
<div class="row g-1 align-items-end">
<div class="col-md-2 col-6">
<label for="quickTimeDate" class="form-label small mb-1">Dato</label>
<input type="date" class="form-control form-control-sm" id="quickTimeDate" name="date"
value="{{ today or '' }}" required>
</div>
<div class="col-md-1 col-3">
<label for="quickTimeHours" class="form-label small mb-1">Timer</label>
<input type="number" class="form-control form-control-sm" id="quickTimeHours" name="hours"
min="0" max="23" value="0" required>
</div>
<div class="col-md-1 col-3">
<label for="quickTimeMinutes" class="form-label small mb-1">Min</label>
<input type="number" class="form-control form-control-sm" id="quickTimeMinutes" name="minutes"
min="0" max="59" step="15" value="0" required>
</div>
<div class="col-md-3 col-6">
<label for="quickTimeBillingMethod" class="form-label small mb-1">Afregning</label>
<select class="form-select form-select-sm" id="quickTimeBillingMethod" name="billing_method">
<option value="invoice" selected>Faktura</option>
{% if prepaid_cards %}
<optgroup label="Klippekort">
{% for card in prepaid_cards %}
<option value="card_{{ card.id }}">💳 Kort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t)</option>
{% endfor %}
</optgroup>
{% endif %}
{% if fixed_price_agreements %}
<optgroup label="Fastpris">
{% for agr in fixed_price_agreements %}
<option value="fpa_{{ agr.id }}">📋 #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t)</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="internal">Internt</option>
<option value="warranty">Garanti</option>
</select>
</div>
<div class="col-md-4 col-12">
<label for="quickTimeDescription" class="form-label small mb-1">Beskrivelse</label>
<input type="text" class="form-control form-control-sm" id="quickTimeDescription" name="description"
placeholder="Hvad har du lavet?" required>
</div>
<div class="col-md-1 col-12 d-flex align-items-end">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-plus-lg me-0"></i>
</button>
</div>
</div>
</form>
</div>
<!-- Time Entries Table -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Dato</th>
<th>Beskrivelse</th>
<th>Bruger</th>
<th class="text-end">Timer</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td>{{ entry.worked_date }}</td>
<td>{{ entry.description or '-' }}</td>
<td>{{ entry.user_name }}</td>
<td class="text-end fw-bold">{{ entry.original_hours }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-3 text-muted">
<i class="bi bi-inbox me-2"></i>Ingen tid registreret endnu
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Prepaid Cards Info -->
{% if prepaid_cards %}
<div class="border-top mt-3 pt-3">
<h6 class="mb-2"><i class="bi bi-credit-card me-1"></i>Aktive Klippekort</h6>
<div class="row g-2">
{% for card in prepaid_cards %}
<div class="col-md-3">
<div class="border rounded p-2 bg-light">
<div class="small text-muted">Kort #{{ card.card_number or card.id }}</div>
<div class="fw-bold text-primary">{{ '%.2f' % card.remaining_hours }} timer tilbage</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div 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>
<span>Titel</span>
<span>Kunde</span>
<span>Slet</span>
</div>
{% for contact in contacts %}
<div
class="contact-row"
role="button"
tabindex="0"
onclick="showContactInfoModal(this)"
data-contact-id="{{ contact.contact_id }}"
data-name="{{ contact.contact_name|replace('"', '&quot;') }}"
data-title="{{ contact.title|default('', true)|replace('"', '&quot;') }}"
data-company="{{ contact.customer_name|default('', true)|replace('"', '&quot;') }}"
data-email="{{ contact.contact_email|default('', true)|replace('"', '&quot;') }}"
data-phone="{{ contact.phone|default('', true)|replace('"', '&quot;') }}"
data-mobile="{{ contact.mobile|default('', true)|replace('"', '&quot;') }}"
data-role="{{ contact.role|default('Kontakt')|replace('"', '&quot;') }}"
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
>
<div class="contact-name">{{ contact.contact_name }}</div>
<small>{{ contact.title or '-' }}</small>
<small>{{ contact.customer_name or '-' }}</small>
<button
class="btn btn-sm btn-delete"
onclick="event.stopPropagation(); removeContact({{ case.id }}, {{ contact.contact_id }})"
title="Slet"
>
</button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kontakter</p>
{% endif %}
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="customers" data-has-content="{{ 'true' if customers else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🏢 Kunder</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openSearchModal('customer')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" id="customers-container" style="max-height: 180px;">
{% if customers %}
<div class="customer-list-header">
<span>Navn</span>
<span>Rolle</span>
<span>E-mail</span>
<span>Slet</span>
</div>
{% for customer in customers %}
<div class="customer-row">
<div>
<a href="/customers/{{ customer.customer_id }}" class="text-decoration-none fw-semibold">
{{ customer.customer_name }}
</a>
</div>
<small>{{ customer.role or '-' }}</small>
<small>{{ customer.customer_email or '-' }}</small>
<button onclick="removeCustomer({{ case.id }}, {{ customer.customer_id }})" class="btn btn-sm btn-delete" title="Slet"></button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kunder</p>
{% endif %}
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="wiki" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent); font-size: 0.85rem;">Kunde-wiki</h6>
</div>
<div class="card-body flex-grow-1 p-0" style="max-height: 220px; overflow: auto;">
<div class="p-2 border-bottom">
<input type="text" class="form-control form-control-sm" id="wikiSearchInput" placeholder="Soeg i Wiki (tom = guide)" style="font-size: 0.8rem;">
</div>
<div class="list-group list-group-flush" id="wiki-list">
<div class="p-3 text-center text-muted">Henter wiki...</div>
</div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="todo-steps" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">✅ Todo-opgaver</h6>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleTodoStepForm()" title="Tilføj opgave">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body p-0 d-flex flex-column" style="max-height: 260px;">
<form id="todoStepForm" class="p-3 border-bottom d-none">
<input type="text" class="form-control form-control-sm mb-2" id="todoStepTitle" placeholder="Opgavetitel" required>
<textarea class="form-control form-control-sm mb-2" id="todoStepDescription" rows="2" placeholder="Kort note (valgfri)"></textarea>
<div class="d-flex gap-2">
<input type="date" class="form-control form-control-sm" id="todoStepDueDate">
<button class="btn btn-sm btn-outline-primary" type="submit">Tilføj</button>
</div>
</form>
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="todo-steps-list">
<div class="p-3 text-center text-muted">Ingen opgaver endnu</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> <!-- End Details Tab -->
<!-- Solution Tab -->
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}">
<!-- Nextcloud Integration Box -->
{% if is_nextcloud %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">☁️ Nextcloud Integration</h6>
{% if nextcloud_instance %}
<span class="badge bg-success" style="background: var(--accent) !important;">Aktiv</span>
{% else %}
<span class="badge bg-warning text-dark">Ingen instans</span>
{% endif %}
</div>
<div class="card-body">
{% if nextcloud_instance %}
<!-- Info Row -->
<div class="d-flex flex-wrap gap-4 mb-3 border-bottom pb-3">
<div>
<span class="text-muted small d-block">Instans</span>
<a href="{{ nextcloud_instance.base_url }}" target="_blank" style="color: var(--accent); text-decoration: none; font-weight: 500;">
{{ nextcloud_instance.base_url }} <i class="bi bi-box-arrow-up-right small"></i>
</a>
</div>
<div>
<span class="text-muted small d-block">Admin Konto</span>
<span class="font-monospace text-dark">{{ nextcloud_instance.username }}</span>
</div>
</div>
<!-- Actions -->
<div>
<span class="text-muted small d-block mb-2">Handlinger</span>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#ncCreateUserModal">
<i class="bi bi-person-plus me-1"></i> Opret bruger
</button>
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#ncDisableUserModal">
<i class="bi bi-person-lock me-1"></i> Luk bruger
</button>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#ncResetPasswordModal">
<i class="bi bi-key me-1"></i> Reset kode
</button>
<button class="btn btn-sm btn-outline-info" data-bs-toggle="modal" data-bs-target="#ncSendGuideModal">
<i class="bi bi-envelope me-1"></i> Send guide
</button>
</div>
</div>
{% else %}
<div class="text-center py-2 text-muted">
<i class="bi bi-exclamation-triangle me-2 text-warning"></i>
Kunden mangler Nextcloud konfiguration
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-lightbulb me-2"></i>Løsning</h6>
{% if not solution or request.query_params.get('edit_solution') %}
<!-- button to create/edit -->
{% endif %}
</div>
<div class="card-body">
{% if solution %}
<div class="mb-3">
<label class="small text-muted">Titel</label>
<div class="fw-bold">{{ solution.title }}</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="small text-muted">Type</label>
<div><span class="badge bg-secondary">{{ solution.solution_type }}</span></div>
</div>
<div class="col-md-6">
<label class="small text-muted">Resultat</label>
<div><span class="badge {{ 'bg-success' if solution.result == 'Løst' else 'bg-warning' }}">{{ solution.result }}</span></div>
</div>
</div>
<div>
<label class="small text-muted">Beskrivelse</label>
<div class="p-3 bg-light rounded" style="white-space: pre-wrap;">{{ solution.description }}</div>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-lightbulb display-4 mb-3 d-block opacity-25"></i>
<p>Ingen løsning registreret endnu.</p>
<button class="btn btn-primary" onclick="showCreateSolutionModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Løsning
</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Varekøb & Salg Tab -->
<div class="tab-pane fade" id="sales" role="tabpanel" tabindex="0" data-module="sales" data-has-content="unknown">
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-bag-check me-2"></i>Salgslinjer</h6>
<span class="badge bg-light text-dark border" id="salesLinesSubtotal">-</span>
<button class="btn btn-sm btn-outline-primary" onclick="openSaleItemModal({ type: 'sale' })">
<i class="bi bi-plus-lg me-1"></i>Tilføj salgslinje
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead class="bg-light">
<tr>
<th class="ps-4">Dato</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Enhed</th>
<th>Enhedspris</th>
<th>Linjesum</th>
<th>Kilde-sag</th>
<th>Status</th>
<th class="text-end pe-4">Handlinger</th>
</tr>
</thead>
<tbody id="saleItemsSalesBody">
<tr>
<td colspan="9" class="text-center py-4 text-muted">Indlæser salgslinjer...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-cart-x me-2"></i>Indkøbslinjer</h6>
<span class="badge bg-light text-dark border" id="purchaseLinesSubtotal">-</span>
<button class="btn btn-sm btn-outline-primary" onclick="openSaleItemModal({ type: 'purchase' })">
<i class="bi bi-plus-lg me-1"></i>Tilføj indkøbslinje
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead class="bg-light">
<tr>
<th class="ps-4">Dato</th>
<th>Beskrivelse</th>
<th>Antal</th>
<th>Enhed</th>
<th>Enhedspris</th>
<th>Linjesum</th>
<th>Kilde-sag</th>
<th>Status</th>
<th class="text-end pe-4">Handlinger</th>
</tr>
</thead>
<tbody id="saleItemsPurchaseBody">
<tr>
<td colspan="9" class="text-center py-4 text-muted">Indlæser indkøbslinjer...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-graph-up-arrow me-2"></i>Salg (samlet)</h6>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted">Total salg</span>
<strong id="salesTotalSale">-</strong>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Netto</span>
<strong id="salesTotalNet">-</strong>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-bag-check me-2"></i>Indkøb (samlet)</h6>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Total køb</span>
<strong id="salesTotalPurchase">-</strong>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid (samlet)</h6>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted">Timer (total)</span>
<strong id="salesTotalHours">-</strong>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Timer (fakturerbar)</span>
<strong id="salesBillableHours">-</strong>
</div>
<div class="small text-muted mt-3">
Inkluderer alle under-sager
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid (samlet)</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" style="vertical-align: middle;">
<thead class="bg-light">
<tr>
<th class="ps-3">Dato</th>
<th>Timer</th>
<th>Kilde-sag</th>
</tr>
</thead>
<tbody id="salesTimeBody">
<tr>
<td colspan="3" class="text-center py-4 text-muted">Indlæser tid...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Subscription Tab -->
<div class="tab-pane fade" id="subscription" role="tabpanel" tabindex="0" data-module="subscription" data-has-content="unknown">
<div class="row g-3">
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-repeat me-2"></i>Abonnement</h6>
<span id="subscriptionStatusBadge" class="badge bg-light text-dark">Ingen</span>
</div>
<div class="card-body">
<div id="subscriptionEmpty" class="text-center text-muted py-3">
<i class="bi bi-receipt-cutoff display-6 mb-3 d-block opacity-25"></i>
<p>Ingen abonnement oprettet endnu.</p>
</div>
<div id="subscriptionDetails" class="d-none">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="small text-muted">Abonnement</label>
<div class="fw-semibold" id="subscriptionNumber">-</div>
</div>
<div class="col-md-4">
<label class="small text-muted">Produkt</label>
<div class="fw-semibold" id="subscriptionProduct">-</div>
</div>
<div class="col-md-4">
<label class="small text-muted">Status</label>
<div class="fw-semibold" id="subscriptionStatusText">-</div>
</div>
<div class="col-md-4">
<label class="small text-muted">Interval</label>
<div class="fw-semibold" id="subscriptionInterval">-</div>
</div>
<div class="col-md-4">
<label class="small text-muted">Pris</label>
<div class="fw-semibold" id="subscriptionPrice">-</div>
</div>
<div class="col-md-4">
<label class="small text-muted">Startdato</label>
<div class="fw-semibold" id="subscriptionStartDate">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Periode start <i class="bi bi-info-circle" title="Nuværende faktureringsperiode"></i></label>
<div class="fw-semibold" id="subscriptionPeriodStart">-</div>
</div>
<div class="col-md-6">
<label class="small text-muted">Næste faktura <i class="bi bi-info-circle" title="Dato for næste automatiske faktura"></i></label>
<div class="fw-semibold" id="subscriptionNextInvoice">-</div>
</div>
</div>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead class="bg-light">
<tr>
<th>Produkt</th>
<th>Beskrivelse</th>
<th class="text-end">Antal</th>
<th class="text-end">Enhedspris</th>
<th class="text-end">Linjesum</th>
</tr>
</thead>
<tbody id="subscriptionItemsBody">
<tr>
<td colspan="5" class="text-center text-muted">Ingen linjer</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-end mb-3">
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
</div>
<div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
</div>
<form id="subscriptionCreateForm" class="row g-3 d-none">
<div class="col-md-3">
<label class="form-label">Interval *</label>
<select class="form-select" id="subscriptionIntervalInput" required>
<option value="daily">Daglig</option>
<option value="biweekly">Hver 14. dag</option>
<option value="monthly" selected>Maaned</option>
<option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Faktura dag *</label>
<input type="number" class="form-control" id="subscriptionBillingDayInput" min="1" max="31" value="1" required>
</div>
<div class="col-md-6">
<label class="form-label">Startdato *</label>
<input type="date" class="form-control" id="subscriptionStartDateInput" required>
</div>
<div class="col-12">
<label class="form-label">Varelinjer *</label>
<div class="table-responsive">
<table class="table table-sm align-middle mb-2">
<thead>
<tr>
<th style="width: 220px;">Produkt</th>
<th>Beskrivelse</th>
<th style="width: 120px;">Antal</th>
<th style="width: 140px;">Enhedspris</th>
<th style="width: 140px;">Linjesum</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody id="subscriptionLineItemsBody">
<tr>
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addSubscriptionLine()">
<i class="bi bi-plus-lg me-1"></i>Tilfoej linje
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openSubscriptionProductModal()">
<i class="bi bi-box me-1"></i>Opret produkt
</button>
<div class="fw-semibold">Total: <span id="subscriptionLinesTotal">0,00 kr</span></div>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Noter</label>
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
</div>
<div class="col-12">
<button type="button" class="btn btn-primary" onclick="createSubscription()">
<i class="bi bi-plus-circle me-1"></i>Opret abonnement
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Subscription Product Modal -->
<div class="modal fade" id="subscriptionProductModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-box"></i> Opret produkt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="subscriptionProductForm">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="subscriptionProductName" required>
</div>
<div class="col-12">
<label class="form-label">Type</label>
<input type="text" class="form-control" id="subscriptionProductType" placeholder="subscription, service">
</div>
<div class="col-6">
<label class="form-label">Status</label>
<select class="form-select" id="subscriptionProductStatus">
<option value="active" selected>Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Salgspris</label>
<input type="number" class="form-control" id="subscriptionProductSalesPrice" step="0.01" min="0">
</div>
<div class="col-12">
<label class="form-label">Faktureringsinterval</label>
<select class="form-select" id="subscriptionProductBillingPeriod">
<option value="">-</option>
<option value="daily">Daglig</option>
<option value="biweekly">Hver 14. dag</option>
<option value="monthly">Maaned</option>
<option value="quarterly">Kvartal</option>
<option value="yearly">Aar</option>
<option value="one_time">Engang</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Kort beskrivelse</label>
<input type="text" class="form-control" id="subscriptionProductDescription">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="createSubscriptionProduct()">
<i class="bi bi-save me-1"></i>Gem produkt
</button>
</div>
</div>
</div>
</div>
<!-- Reminders Tab -->
<div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown">
<div class="row g-3">
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-bell me-2"></i>Reminders</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openCreateReminderModal()">
<i class="bi bi-plus-lg me-1"></i>Opret reminder
</button>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush" id="remindersList">
<div class="p-4 text-center text-muted">Indlæser reminders...</div>
</div>
</div>
</div>
<div class="card mb-3" id="caseCalendarCard" data-module="calendar" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-calendar3 me-2"></i>Kalenderaftaler</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openCreateReminderModal('meeting')">
<i class="bi bi-plus-lg me-1"></i>Opret aftale
</button>
</div>
<div class="card-body">
<div class="mb-3">
<div class="small text-muted mb-2">Denne sag</div>
<div class="list-group" id="caseCalendarCurrent">
<div class="text-muted small">Indlæser aftaler...</div>
</div>
</div>
<div>
<div class="small text-muted mb-2">Børnesager</div>
<div id="caseCalendarChildren">
<div class="text-muted small">Indlæser børnesager...</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0 text-primary"><i class="bi bi-sliders me-2"></i>Indstillinger</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-2">Reminders følger brugerens standardindstillinger (email, Mattermost og popup), medmindre du vælger at overskrive dem på reminderen.</p>
<div class="small text-muted">
Tip: Brug "Status ændring" hvis reminderen skal trigges af status.
</div>
</div>
</div>
</div>
</div>
</div>
</div> <!-- End Tab Content -->
<!-- Create Reminder Modal -->
<div class="modal fade" id="createReminderModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret reminder</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createReminderForm">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="rem_title" required>
</div>
<div class="col-md-4">
<label class="form-label">Prioritet</label>
<select class="form-select" id="rem_priority">
<option value="low">Lav</option>
<option value="normal" selected>Normal</option>
<option value="high">Høj</option>
<option value="urgent">Kritisk</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Aftaletype</label>
<select class="form-select" id="rem_event_type">
<option value="reminder" selected>Reminder</option>
<option value="meeting">Moede</option>
<option value="technician_visit">Teknikerbesoeg</option>
<option value="obs">OBS</option>
<option value="deadline">Deadline</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Besked</label>
<textarea class="form-control" id="rem_message" rows="3"></textarea>
</div>
<div class="col-md-6">
<label class="form-label">Trigger type</label>
<select class="form-select" id="rem_trigger_type" onchange="updateReminderTriggerFields()">
<option value="time_based" selected>Tidspunkt</option>
<option value="status_change">Status ændring</option>
</select>
</div>
<div class="col-md-6" id="rem_trigger_time_wrap">
<label class="form-label">Tidspunkt</label>
<input type="datetime-local" class="form-control" id="rem_scheduled_at">
</div>
<div class="col-md-6 d-none" id="rem_trigger_status_wrap">
<label class="form-label">Status (target)</label>
<select class="form-select" id="rem_target_status">
<option value="">Vælg status</option>
{% for status in status_options %}
<option value="{{ status }}">{{ status }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Gentagelse</label>
<select class="form-select" id="rem_recurrence_type" onchange="updateReminderRecurrenceFields()">
<option value="once" selected>Kun én gang</option>
<option value="daily">Dagligt</option>
<option value="weekly">Ugentligt</option>
<option value="monthly">Månedligt</option>
</select>
</div>
<div class="col-md-6 d-none" id="rem_recurrence_dow_wrap">
<label class="form-label">Ugedag</label>
<select class="form-select" id="rem_recurrence_dow">
<option value="0">Mandag</option>
<option value="1">Tirsdag</option>
<option value="2">Onsdag</option>
<option value="3">Torsdag</option>
<option value="4">Fredag</option>
<option value="5">Lørdag</option>
<option value="6">Søndag</option>
</select>
</div>
<div class="col-md-6 d-none" id="rem_recurrence_dom_wrap">
<label class="form-label">Dag i måned</label>
<input type="number" class="form-control" id="rem_recurrence_dom" min="1" max="31" placeholder="Fx 15">
</div>
<div class="col-12">
<label class="form-label">Kanaler</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rem_notify_frontend" checked>
<label class="form-check-label" for="rem_notify_frontend">Popup</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rem_notify_email">
<label class="form-check-label" for="rem_notify_email">E-mail</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rem_notify_mattermost">
<label class="form-check-label" for="rem_notify_mattermost">Mattermost</label>
</div>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rem_override_prefs">
<label class="form-check-label" for="rem_override_prefs">Overskriv brugerens standardindstillinger</label>
</div>
</div>
<div class="col-12">
<div class="alert alert-warning small mb-0 d-none" id="rem_user_warning">
Mangler bruger-id. Log ind igen eller opdater siden.
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveReminder()">Gem reminder</button>
</div>
</div>
</div>
</div>
<!-- Global Comments Section (Visible on all tabs) -->
<div class="row mb-4 mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-chat-left-text me-2"></i>Kommentarer</h5>
<span class="badge bg-secondary">{{ comments|length if comments else 0 }}</span>
</div>
<div class="card-body bg-light" style="max-height: 500px; overflow-y: auto;" id="comments-container">
{% if comments %}
{% for comment in comments %}
<div class="d-flex mb-3 {{ 'justify-content-end' if comment.forfatter == 'System' else '' }}">
<div class="card {{ 'border-info' if comment.forfatter == 'System' else '' }}" style="max-width: 80%; width: fit-content;">
<div class="card-header py-1 px-3 small {{ 'bg-info text-white' if comment.forfatter == 'System' else 'bg-secondary text-white' }} d-flex justify-content-between align-items-center gap-3">
<strong>{{ comment.forfatter }}</strong>
<span>{{ comment.created_at.strftime('%d/%m-%Y %H:%M') }}</span>
</div>
<div class="card-body py-2 px-3">
{{ comment.indhold|replace('\n', '<br>')|safe }}
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted my-3">Ingen kommentarer endnu.</p>
{% endif %}
</div>
<div class="card-footer bg-white">
<form id="comment-form" onsubmit="submitComment(event)">
<div class="input-group">
<textarea class="form-control" name="indhold" required placeholder="Skriv en kommentar..." rows="2" style="resize: none;"></textarea>
<button type="submit" class="btn btn-primary d-flex align-items-center">
<i class="bi bi-send me-2"></i> Send
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
async function submitComment(event) {
event.preventDefault();
const form = event.target;
const content = form.indhold.value;
const btn = form.querySelector('button');
const originalText = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Sender...';
btn.disabled = true;
try {
const response = await fetch('/api/v1/sag/{{ case.id }}/kommentarer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
indhold: content,
forfatter: "Bruger"
})
});
if (response.ok) {
location.reload();
} else {
alert('Fejl ved oprettelse af kommentar');
btn.innerHTML = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('Der skete en fejl. Prøv igen.');
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// Scroll to bottom of comments
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('comments-container');
if(container) {
container.scrollTop = container.scrollHeight;
}
});
</script>
<script>
const salesCaseId = {{ case.id }};
function formatCurrency(value) {
const num = Number(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(num);
}
function formatNumber(value) {
const num = Number(value || 0);
return new Intl.NumberFormat('da-DK', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(num);
}
let saleItemsCache = [];
async function loadVarekobSalg() {
try {
const res = await fetch(`/api/v1/sag/${salesCaseId}/varekob-salg?include_subcases=true`);
if (!res.ok) throw new Error('Failed to load aggregated data');
const data = await res.json();
document.getElementById('salesTotalPurchase').textContent = formatCurrency(data?.totals?.purchase_total);
document.getElementById('salesTotalSale').textContent = formatCurrency(data?.totals?.sale_total);
document.getElementById('salesTotalNet').textContent = formatCurrency(data?.totals?.net_total);
document.getElementById('salesTotalHours').textContent = formatNumber(data?.totals?.total_hours) + ' t';
document.getElementById('salesBillableHours').textContent = formatNumber(data?.totals?.billable_hours) + ' t';
saleItemsCache = data.sale_items || [];
renderSaleItems(saleItemsCache);
renderTimeEntries(data.time_entries || []);
const hasSalesData = (data.sale_items || []).length > 0 || (data.time_entries || []).length > 0;
setModuleContentState('sales', hasSalesData);
} catch (error) {
console.error(error);
const saleBody = document.getElementById('saleItemsBody');
if (saleBody) {
saleBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
}
const timeBody = document.getElementById('salesTimeBody');
if (timeBody) {
timeBody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
}
setModuleContentState('sales', true);
}
}
function renderSaleItems(items) {
const salesBody = document.getElementById('saleItemsSalesBody');
const purchaseBody = document.getElementById('saleItemsPurchaseBody');
const salesSubtotal = document.getElementById('salesLinesSubtotal');
const purchaseSubtotal = document.getElementById('purchaseLinesSubtotal');
if (!salesBody || !purchaseBody) return;
const salesItems = items.filter(item => (item.type || '').toLowerCase() !== 'purchase');
const purchaseItems = items.filter(item => (item.type || '').toLowerCase() === 'purchase');
const renderRows = (list) => {
if (!list.length) {
return '<tr><td colspan="9" class="text-center py-4 text-muted">Ingen linjer</td></tr>';
}
return list.map(item => {
const statusLabel = item.status || 'draft';
const isSubcase = item.sag_id && item.sag_id !== salesCaseId;
const sourceBadge = isSubcase
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
return `
<tr>
<td class="ps-4">${item.line_date || '-'}</td>
<td>${item.description || '-'}</td>
<td>${item.quantity ?? '-'}</td>
<td>${item.unit || '-'}</td>
<td>${item.unit_price != null ? formatCurrency(item.unit_price) : '-'}</td>
<td class="fw-bold">${formatCurrency(item.amount)}</td>
<td>${item.source_sag_titel || '-'}${sourceBadge}</td>
<td><span class="badge bg-light text-dark border">${statusLabel}</span></td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" onclick='openSaleItemModalById(${item.id})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger" onclick='deleteSaleItem(${item.id})'><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`;
}).join('');
};
salesBody.innerHTML = renderRows(salesItems);
purchaseBody.innerHTML = renderRows(purchaseItems);
const salesSum = salesItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const purchaseSum = purchaseItems.reduce((sum, item) => sum + Number(item.amount || 0), 0);
if (salesSubtotal) salesSubtotal.textContent = formatCurrency(salesSum);
if (purchaseSubtotal) purchaseSubtotal.textContent = formatCurrency(purchaseSum);
}
function renderTimeEntries(entries) {
const tbody = document.getElementById('salesTimeBody');
if (!tbody) return;
if (!entries.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Ingen tid registreret</td></tr>';
return;
}
tbody.innerHTML = entries.map(entry => {
const hours = entry.approved_hours || entry.original_hours || 0;
const isSubcase = entry.sag_id && entry.sag_id !== salesCaseId;
const sourceBadge = isSubcase
? `<span class="badge bg-warning text-dark ms-2">Under-sag</span>`
: `<span class="badge bg-light text-dark border ms-2">Denne sag</span>`;
return `
<tr>
<td class="ps-3">${entry.worked_date || '-'}</td>
<td>${formatNumber(hours)} t</td>
<td>${entry.source_sag_titel || '-'}${sourceBadge}</td>
</tr>
`;
}).join('');
}
function openSaleItemModal(item = null) {
document.getElementById('sale_item_id').value = item?.id || '';
document.getElementById('sale_type').value = item?.type || 'sale';
document.getElementById('sale_status').value = item?.status || 'draft';
document.getElementById('sale_date').value = item?.line_date || '';
document.getElementById('sale_description').value = item?.description || '';
document.getElementById('sale_quantity').value = item?.quantity ?? '';
document.getElementById('sale_unit').value = item?.unit || '';
document.getElementById('sale_unit_price').value = item?.unit_price ?? '';
document.getElementById('sale_amount').value = item?.amount ?? '';
document.getElementById('sale_currency').value = item?.currency || 'DKK';
document.getElementById('sale_external_ref').value = item?.external_ref || '';
new bootstrap.Modal(document.getElementById('saleItemModal')).show();
}
function openSaleItemModalById(itemId) {
const item = saleItemsCache.find((entry) => entry.id === itemId);
openSaleItemModal(item || null);
}
function updateSaleAmount() {
const qty = parseFloat(document.getElementById('sale_quantity').value || 0);
const price = parseFloat(document.getElementById('sale_unit_price').value || 0);
if (qty && price) {
document.getElementById('sale_amount').value = (qty * price).toFixed(2);
}
}
async function saveSaleItem() {
const itemId = document.getElementById('sale_item_id').value;
const payload = {
type: document.getElementById('sale_type').value,
status: document.getElementById('sale_status').value,
line_date: document.getElementById('sale_date').value || null,
description: document.getElementById('sale_description').value,
quantity: document.getElementById('sale_quantity').value || null,
unit: document.getElementById('sale_unit').value || null,
unit_price: document.getElementById('sale_unit_price').value || null,
amount: document.getElementById('sale_amount').value,
currency: document.getElementById('sale_currency').value || 'DKK',
external_ref: document.getElementById('sale_external_ref').value || null
};
if (!payload.description || !payload.amount) {
alert('Beskrivelse og linjesum er påkrævet.');
return;
}
const method = itemId ? 'PATCH' : 'POST';
const url = itemId
? `/api/v1/sag/${salesCaseId}/sale-items/${itemId}`
: `/api/v1/sag/${salesCaseId}/sale-items`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
alert('Kunne ikke gemme varelinje');
return;
}
bootstrap.Modal.getInstance(document.getElementById('saleItemModal')).hide();
await loadVarekobSalg();
}
async function deleteSaleItem(itemId) {
if (!confirm('Vil du slette denne varelinje?')) return;
const res = await fetch(`/api/v1/sag/${salesCaseId}/sale-items/${itemId}`, { method: 'DELETE' });
if (!res.ok) {
alert('Kunne ikke slette varelinje');
return;
}
await loadVarekobSalg();
}
document.addEventListener('DOMContentLoaded', function() {
const qtyInput = document.getElementById('sale_quantity');
const priceInput = document.getElementById('sale_unit_price');
if (qtyInput) qtyInput.addEventListener('input', updateSaleAmount);
if (priceInput) priceInput.addEventListener('input', updateSaleAmount);
loadVarekobSalg();
});
</script>
<script>
let reminderUserId = null;
function getReminderUserId() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.sub || payload.user_id;
} catch (e) {
console.warn('Could not decode token for reminder user_id');
}
}
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) return metaTag.getAttribute('content');
return null;
}
function formatReminderDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('da-DK', { hour12: false });
}
function updateReminderTriggerFields() {
const triggerType = document.getElementById('rem_trigger_type')?.value;
const timeWrap = document.getElementById('rem_trigger_time_wrap');
const statusWrap = document.getElementById('rem_trigger_status_wrap');
if (timeWrap && statusWrap) {
if (triggerType === 'status_change') {
timeWrap.classList.add('d-none');
statusWrap.classList.remove('d-none');
} else {
timeWrap.classList.remove('d-none');
statusWrap.classList.add('d-none');
}
}
}
function updateReminderRecurrenceFields() {
const recurrenceType = document.getElementById('rem_recurrence_type')?.value;
const dowWrap = document.getElementById('rem_recurrence_dow_wrap');
const domWrap = document.getElementById('rem_recurrence_dom_wrap');
if (!dowWrap || !domWrap) return;
dowWrap.classList.toggle('d-none', recurrenceType !== 'weekly');
domWrap.classList.toggle('d-none', recurrenceType !== 'monthly');
}
function openCreateReminderModal(defaultEventType) {
reminderUserId = getReminderUserId();
const warning = document.getElementById('rem_user_warning');
if (warning) warning.classList.toggle('d-none', !!reminderUserId);
const form = document.getElementById('createReminderForm');
if (form) form.reset();
document.getElementById('rem_notify_frontend').checked = true;
document.getElementById('rem_priority').value = 'normal';
document.getElementById('rem_event_type').value = defaultEventType || 'reminder';
document.getElementById('rem_trigger_type').value = 'time_based';
document.getElementById('rem_recurrence_type').value = 'once';
updateReminderTriggerFields();
updateReminderRecurrenceFields();
new bootstrap.Modal(document.getElementById('createReminderModal')).show();
}
async function loadReminders() {
const list = document.getElementById('remindersList');
if (!list) return;
reminderUserId = getReminderUserId();
if (!reminderUserId) {
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke finde bruger-id.</div>';
setModuleContentState('reminders', true);
return;
}
list.innerHTML = '<div class="p-4 text-center text-muted"><span class="spinner-border spinner-border-sm"></span> Henter reminders...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseIds}/reminders?user_id=${reminderUserId}`);
if (!res.ok) throw new Error('Kunne ikke hente reminders');
const reminders = await res.json();
renderReminders(reminders);
} catch (e) {
console.error(e);
list.innerHTML = '<div class="p-4 text-center text-danger">Fejl ved hentning af reminders</div>';
setModuleContentState('reminders', true);
}
}
function renderReminders(reminders) {
const list = document.getElementById('remindersList');
if (!list) return;
if (!reminders || reminders.length === 0) {
list.innerHTML = '<div class="p-4 text-center text-muted">Ingen reminders endnu.</div>';
setModuleContentState('reminders', false);
return;
}
const triggerLabels = {
time_based: 'Tidspunkt',
status_change: 'Status ændring',
deadline_approaching: 'Deadline'
};
const eventTypeLabels = {
reminder: 'Reminder',
meeting: 'Moede',
technician_visit: 'Teknikerbesoeg',
obs: 'OBS',
deadline: 'Deadline'
};
const recurrenceLabels = {
once: 'Én gang',
daily: 'Dagligt',
weekly: 'Ugentligt',
monthly: 'Månedligt'
};
list.innerHTML = reminders.map(reminder => {
const nextCheck = formatReminderDate(reminder.next_check_at);
const createdAt = formatReminderDate(reminder.created_at);
const isActive = reminder.is_active;
const statusBadge = isActive
? '<span class="badge bg-success">Aktiv</span>'
: '<span class="badge bg-secondary">Inaktiv</span>';
return `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3">
<div class="fw-bold">${reminder.title}</div>
<div class="text-muted small">${reminder.message || '-'} </div>
<div class="small text-muted mt-1">
Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type}
</div>
<div class="small text-muted">Næste: ${nextCheck} · Oprettet: ${createdAt}</div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
${statusBadge}
<button class="btn btn-sm btn-outline-danger" onclick="deleteReminder(${reminder.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`;
}).join('');
setModuleContentState('reminders', true);
}
async function saveReminder() {
reminderUserId = getReminderUserId();
if (!reminderUserId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
const title = document.getElementById('rem_title').value.trim();
const message = document.getElementById('rem_message').value.trim();
const priority = document.getElementById('rem_priority').value;
const eventType = document.getElementById('rem_event_type').value;
const triggerType = document.getElementById('rem_trigger_type').value;
const scheduledAtValue = document.getElementById('rem_scheduled_at').value;
const targetStatus = document.getElementById('rem_target_status').value;
const recurrenceType = document.getElementById('rem_recurrence_type').value;
const recurrenceDow = document.getElementById('rem_recurrence_dow').value;
const recurrenceDom = document.getElementById('rem_recurrence_dom').value;
const notifyFrontend = document.getElementById('rem_notify_frontend').checked;
const notifyEmail = document.getElementById('rem_notify_email').checked;
const notifyMattermost = document.getElementById('rem_notify_mattermost').checked;
const overridePrefs = document.getElementById('rem_override_prefs').checked;
if (!title) {
alert('Titel er påkrævet');
return;
}
let triggerConfig = {};
let scheduledAt = null;
if (triggerType === 'status_change') {
if (!targetStatus) {
alert('Vælg en status for statusændring');
return;
}
triggerConfig = { target_status: targetStatus };
} else {
if (!scheduledAtValue) {
alert('Vælg et tidspunkt');
return;
}
scheduledAt = new Date(scheduledAtValue).toISOString();
}
const payload = {
title,
message: message || null,
priority,
event_type: eventType,
trigger_type: triggerType,
trigger_config: triggerConfig,
recipient_user_ids: [Number(reminderUserId)],
recipient_emails: [],
notify_mattermost: notifyMattermost,
notify_email: notifyEmail,
notify_frontend: notifyFrontend,
override_user_preferences: overridePrefs,
recurrence_type: recurrenceType,
recurrence_day_of_week: recurrenceType === 'weekly' ? Number(recurrenceDow) : null,
recurrence_day_of_month: recurrenceType === 'monthly' ? Number(recurrenceDom) : null,
scheduled_at: scheduledAt
};
try {
const res = await fetch(`/api/v1/sag/${caseIds}/reminders?user_id=${reminderUserId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette reminder');
}
bootstrap.Modal.getInstance(document.getElementById('createReminderModal')).hide();
await loadReminders();
await loadCaseCalendar();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function deleteReminder(reminderId) {
if (!confirm('Vil du slette denne reminder?')) return;
try {
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Kunne ikke slette reminder');
await loadReminders();
await loadCaseCalendar();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
function formatCalendarEvent(event) {
const dateLabel = formatReminderDate(event.start);
const typeLabelMap = {
reminder: 'Reminder',
meeting: 'Moede',
technician_visit: 'Teknikerbesoeg',
obs: 'OBS',
deadline: 'Deadline',
deferred: 'Deferred'
};
const typeLabel = typeLabelMap[event.event_kind] || event.event_kind || 'Reminder';
return `
<a href="${event.url}" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between">
<div>
<div class="fw-semibold">${event.title || 'Aftale'}</div>
<div class="text-muted small">${typeLabel} · ${dateLabel}</div>
</div>
</div>
</a>
`;
}
async function loadCaseCalendar() {
const currentList = document.getElementById('caseCalendarCurrent');
const childrenList = document.getElementById('caseCalendarChildren');
if (!currentList || !childrenList) return;
currentList.innerHTML = '<div class="text-muted small">Indlæser aftaler...</div>';
childrenList.innerHTML = '<div class="text-muted small">Indlæser børnesager...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseIds}/calendar-events?include_children=true`);
if (!res.ok) throw new Error('Kunne ikke hente kalenderaftaler');
const data = await res.json();
const currentEvents = data.current || [];
const childGroups = data.children || [];
const childCount = childGroups.reduce((sum, child) => sum + (child.events || []).length, 0);
const hasAnyEvents = currentEvents.length > 0 || childCount > 0;
if (!currentEvents.length) {
currentList.innerHTML = '<div class="text-muted small">Ingen aftaler for denne sag.</div>';
} else {
currentList.innerHTML = currentEvents
.map(formatCalendarEvent)
.join('');
}
if (!childGroups.length) {
childrenList.innerHTML = '<div class="text-muted small">Ingen børnesager.</div>';
} else {
childrenList.innerHTML = childGroups.map(child => {
const eventsHtml = (child.events || []).length
? child.events.map(formatCalendarEvent).join('')
: '<div class="text-muted small">Ingen aftaler.</div>';
return `
<div class="mb-3">
<div class="fw-semibold mb-1">${child.case_title}</div>
<div class="list-group">
${eventsHtml}
</div>
</div>
`;
}).join('');
}
setModuleContentState('calendar', hasAnyEvents);
} catch (e) {
console.error(e);
currentList.innerHTML = '<div class="text-danger small">Fejl ved hentning af aftaler.</div>';
childrenList.innerHTML = '';
setModuleContentState('calendar', true);
}
}
document.addEventListener('DOMContentLoaded', function() {
updateReminderTriggerFields();
updateReminderRecurrenceFields();
loadReminders();
loadCaseCalendar();
});
</script>
<!-- Modals for Solution (Inserted here) -->
<div class="modal fade" id="createSolutionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Løsning</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="solutionForm">
<input type="hidden" id="sol_sag_id" value="{{ case.id }}">
<div class="mb-3">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="sol_title" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Type</label>
<select class="form-select" id="sol_type">
<option value="Support">Support</option>
<option value="Drift">Drift</option>
<option value="Konsulent">Konsulent</option>
<option value="Infrastruktur">Infrastruktur</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Resultat</label>
<select class="form-select" id="sol_result">
<option value="Løst">Løst</option>
<option value="Delvist">Delvist</option>
<option value="Workaround">Workaround</option>
<option value="Ej løst">Ej løst</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="sol_desc" rows="5"></textarea>
</div>
<div class="border-top pt-3">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="sol_add_time">
<label class="form-check-label" for="sol_add_time">
Registrer tid med det samme
</label>
</div>
<div id="sol_time_fields" class="row g-3 d-none">
<div class="col-md-4">
<label class="form-label">Dato</label>
<input type="date" class="form-control" id="sol_time_date">
</div>
<div class="col-md-4">
<label class="form-label">Tid brugt</label>
<div class="input-group">
<input type="number" class="form-control" id="sol_time_hours" min="0" placeholder="tt" step="1">
<span class="input-group-text">:</span>
<input type="number" class="form-control" id="sol_time_minutes" min="0" placeholder="mm" step="1">
</div>
<div class="form-text" id="sol_time_total">Total: 0.00 timer</div>
</div>
<div class="col-md-4">
<label class="form-label">Beskrivelse</label>
<input type="text" class="form-control" id="sol_time_desc" placeholder="F.eks. afsluttede løsning">
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="sol_time_internal">
<label class="form-check-label text-muted" for="sol_time_internal">
Skjul for kunde (intern registrering)
</label>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveSolution()">Gem Løsning</button>
</div>
</div>
</div>
</div>
<!-- Modal for Sale Item -->
<div class="modal fade" id="saleItemModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-basket3"></i> Varelinje</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="saleItemForm">
<input type="hidden" id="sale_item_id">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Type *</label>
<select class="form-select" id="sale_type">
<option value="sale">Salg</option>
<option value="purchase">Køb</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Status *</label>
<select class="form-select" id="sale_status">
<option value="draft">Kladde</option>
<option value="confirmed">Bekræftet</option>
<option value="cancelled">Annulleret</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Dato</label>
<input type="date" class="form-control" id="sale_date">
</div>
<div class="col-12">
<label class="form-label">Beskrivelse *</label>
<input type="text" class="form-control" id="sale_description" placeholder="F.eks. Switch, montage, kørsel">
</div>
<div class="col-md-3">
<label class="form-label">Antal</label>
<input type="number" class="form-control" id="sale_quantity" step="0.01" min="0">
</div>
<div class="col-md-3">
<label class="form-label">Enhed</label>
<input type="text" class="form-control" id="sale_unit" placeholder="stk, timer">
</div>
<div class="col-md-3">
<label class="form-label">Enhedspris</label>
<input type="number" class="form-control" id="sale_unit_price" step="0.01" min="0">
</div>
<div class="col-md-3">
<label class="form-label">Linjesum *</label>
<input type="number" class="form-control" id="sale_amount" step="0.01" min="0">
</div>
<div class="col-md-4">
<label class="form-label">Valuta</label>
<input type="text" class="form-control" id="sale_currency" value="DKK">
</div>
<div class="col-md-8">
<label class="form-label">Reference</label>
<input type="text" class="form-control" id="sale_external_ref" placeholder="Valgfri reference">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveSaleItem()">Gem varelinje</button>
</div>
</div>
</div>
</div>
<!-- Modal for Internal Time -->
<div class="modal fade" id="createTimeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-clock-history"></i> Registrer Tid</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="timeForm">
<input type="hidden" id="time_sag_id" value="{{ case.id }}">
<div class="row g-3">
<div class="col-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="time_date" required>
</div>
<div class="col-6">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<input type="number" class="form-control" id="time_hours_input" min="0" placeholder="tt" step="1">
<span class="input-group-text">:</span>
<input type="number" class="form-control" id="time_minutes_input" min="0" max="59" placeholder="mm" step="1">
</div>
<div class="form-text text-end" id="timeTotalCalc">Total: 0.00 timer</div>
</div>
<div class="col-6">
<label class="form-label">Type</label>
<select class="form-select" id="time_work_type">
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Afregning</label>
<select class="form-select" id="time_billing_method">
<option value="invoice" selected>Faktura</option>
{% if prepaid_cards %}
<optgroup label="Klippekort">
{% for card in prepaid_cards %}
<option value="card_{{ card.id }}">💳 Klippekort #{{ card.card_number or card.id }} ({{ '%.2f' % card.remaining_hours }}t tilbage{% if card.expires_at %} • Udløber {{ card.expires_at }}{% endif %})</option>
{% endfor %}
</optgroup>
{% endif %}
{% if fixed_price_agreements %}
<optgroup label="Fastpris Aftaler">
{% for agr in fixed_price_agreements %}
<option value="fpa_{{ agr.id }}">📋 Fastpris #{{ agr.agreement_number }} ({{ '%.1f' % agr.remaining_hours_this_month }}t tilbage / {{ '%.0f' % agr.monthly_hours }}t/måned)</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="time_desc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="time_internal">
<label class="form-check-label text-muted" for="time_internal">
Skjul for kunde (Intern registrering)
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveTime()">
<i class="bi bi-save"></i> Gem Tid
</button>
</div>
</div>
</div>
</div>
<!-- Script for Solution/Time -->
<script>
function showCreateSolutionModal() {
const addTimeCheckbox = document.getElementById('sol_add_time');
const timeFields = document.getElementById('sol_time_fields');
if (addTimeCheckbox && timeFields) {
addTimeCheckbox.checked = false;
timeFields.classList.add('d-none');
}
const timeDate = document.getElementById('sol_time_date');
if (timeDate) timeDate.valueAsDate = new Date();
const timeHours = document.getElementById('sol_time_hours');
const timeMinutes = document.getElementById('sol_time_minutes');
const timeTotal = document.getElementById('sol_time_total');
if (timeHours) timeHours.value = '';
if (timeMinutes) timeMinutes.value = '';
if (timeTotal) timeTotal.textContent = 'Total: 0.00 timer';
const timeDesc = document.getElementById('sol_time_desc');
if (timeDesc) timeDesc.value = '';
const timeInternal = document.getElementById('sol_time_internal');
if (timeInternal) timeInternal.checked = false;
new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
}
function updateSolutionTimeTotal() {
const h = parseInt(document.getElementById('sol_time_hours').value) || 0;
const m = parseInt(document.getElementById('sol_time_minutes').value) || 0;
const total = h + (m / 60);
const output = document.getElementById('sol_time_total');
if (output) output.textContent = `Total: ${total.toFixed(2)} timer`;
}
async function saveSolution() {
const data = {
sag_id: document.getElementById('sol_sag_id').value,
title: document.getElementById('sol_title').value,
solution_type: document.getElementById('sol_type').value,
result: document.getElementById('sol_result').value,
description: document.getElementById('sol_desc').value,
created_by_user_id: 1 // TODO: Get from auth
};
const addTime = document.getElementById('sol_add_time')?.checked;
const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0;
const timeMinutes = parseInt(document.getElementById('sol_time_minutes').value) || 0;
const timeTotal = timeHours + (timeMinutes / 60);
try {
const res = await fetch(`/api/v1/sag/${data.sag_id}/solution`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (res.ok) {
if (addTime && timeTotal > 0) {
const solution = await res.json();
const timePayload = {
sag_id: data.sag_id,
solution_id: solution.id,
description: document.getElementById('sol_time_desc').value || data.title,
original_hours: timeTotal,
worked_date: document.getElementById('sol_time_date').value || null,
is_internal: document.getElementById('sol_time_internal').checked,
work_type: 'support'
};
const timeRes = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(timePayload)
});
if (!timeRes.ok) {
alert('Løsning oprettet, men tid kunne ikke registreres');
}
}
window.location.reload();
} else {
alert('Fejl ved oprettelse af løsning');
}
} catch(e) { console.error(e); alert('Fejl'); }
}
function showAddTimeModal() {
// Set date to today
document.getElementById('time_date').valueAsDate = new Date();
// Reset fields
if(document.getElementById('time_hours_input')) {
document.getElementById('time_hours_input').value = '';
document.getElementById('time_minutes_input').value = '';
document.getElementById('timeTotalCalc').textContent = 'Total: 0.00 timer';
}
document.getElementById('time_desc').value = '';
if(document.getElementById('time_internal')) document.getElementById('time_internal').checked = false;
if(document.getElementById('time_billing_method')) document.getElementById('time_billing_method').value = 'invoice';
if(document.getElementById('time_work_type')) document.getElementById('time_work_type').value = 'support';
new bootstrap.Modal(document.getElementById('createTimeModal')).show();
}
// Auto-calculate total hours
function updateTimeTotal() {
const h = parseInt(document.getElementById('time_hours_input').value) || 0;
const m = parseInt(document.getElementById('time_minutes_input').value) || 0;
const total = h + (m / 60);
if(document.getElementById('timeTotalCalc')) {
document.getElementById('timeTotalCalc').textContent = `Total: ${total.toFixed(2)} timer`;
}
}
// Add listeners safely
document.addEventListener('DOMContentLoaded', () => {
const hInput = document.getElementById('time_hours_input');
const mInput = document.getElementById('time_minutes_input');
if(hInput) hInput.addEventListener('input', updateTimeTotal);
if(mInput) mInput.addEventListener('input', updateTimeTotal);
const solAddTime = document.getElementById('sol_add_time');
const solFields = document.getElementById('sol_time_fields');
if (solAddTime && solFields) {
solAddTime.addEventListener('change', () => {
solFields.classList.toggle('d-none', !solAddTime.checked);
});
}
const solHours = document.getElementById('sol_time_hours');
const solMinutes = document.getElementById('sol_time_minutes');
if (solHours) solHours.addEventListener('input', updateSolutionTimeTotal);
if (solMinutes) solMinutes.addEventListener('input', updateSolutionTimeTotal);
});
async function saveTime() {
let totalHours = 0;
// Check if we are using the new split inputs
const hInput = document.getElementById('time_hours_input');
if (hInput) {
const h = parseInt(hInput.value) || 0;
const m = parseInt(document.getElementById('time_minutes_input').value) || 0;
totalHours = h + (m/60);
} else {
// Fallback to old input if modal replacement didn't work (shouldn't happen)
totalHours = parseFloat(document.getElementById('time_hours').value) || 0;
}
if (totalHours <= 0) { alert('Indtast tid'); return; }
const billingSelect = document.getElementById('time_billing_method');
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
let prepaidCardId = null;
let fixedPriceAgreementId = null;
// Handle prepaid card selection formatting (card_123)
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
// Handle fixed-price agreement selection formatting (fpa_123)
if (billingMethod.startsWith('fpa_')) {
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'fixed_price';
}
const workTypeSelect = document.getElementById('time_work_type');
const internalCheck = document.getElementById('time_internal');
const data = {
sag_id: parseInt(document.getElementById('time_sag_id').value),
original_hours: totalHours,
description: document.getElementById('time_desc').value,
worked_date: document.getElementById('time_date').value,
work_type: workTypeSelect ? workTypeSelect.value : 'support',
billing_method: billingMethod,
is_internal: internalCheck ? internalCheck.checked : false
};
if (prepaidCardId) {
data.prepaid_card_id = prepaidCardId;
}
if (fixedPriceAgreementId) {
data.fixed_price_agreement_id = fixedPriceAgreementId;
}
try {
const res = await fetch(`/api/v1/timetracking/entries/internal`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (res.ok) {
window.location.reload();
} else {
const txt = await res.text();
alert('Fejl: ' + txt);
}
} catch(e) { console.error(e); alert('Fejl'); }
}
</script>
<!-- Kontakt Info Modal -->
<div class="modal fade" id="kontaktModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="background: var(--accent); color: white;">
<h5 class="modal-title">
<i class="bi bi-person-circle me-2"></i>Kontakt Information
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if hovedkontakt %}
<div class="mb-3">
<label class="small text-muted mb-1">Navn</label>
<div class="fw-bold">{{ hovedkontakt.first_name }} {{ hovedkontakt.last_name }}</div>
</div>
<div class="mb-3">
<label class="small text-muted mb-1">E-mail</label>
<div>
{% if hovedkontakt.email %}
<a href="mailto:{{ hovedkontakt.email }}" style="color: var(--accent);">
<i class="bi bi-envelope me-1"></i>{{ hovedkontakt.email }}
</a>
{% else %}
<span class="text-muted">Ingen email</span>
{% endif %}
</div>
</div>
<div class="mb-3">
<label class="small text-muted mb-1">Telefon</label>
<div>
{% if hovedkontakt.phone %}
<a href="tel:{{ hovedkontakt.phone }}" style="color: var(--accent);">
<i class="bi bi-telephone me-1"></i>{{ hovedkontakt.phone }}
</a>
{% else %}
<span class="text-muted">Ingen telefon</span>
{% endif %}
</div>
</div>
<div class="mb-3">
<label class="small text-muted mb-1">Mobil</label>
<div>
{% if hovedkontakt.mobile %}
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="tel:{{ hovedkontakt.mobile }}" style="color: var(--accent);">
<i class="bi bi-phone me-1"></i>{{ hovedkontakt.mobile }}
</a>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt({{ hovedkontakt.mobile|tojson }}, {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|tojson }}, {{ hovedkontakt.id|default('null') }})">SMS</button>
</div>
{% else %}
<span class="text-muted">Ingen mobil</span>
{% endif %}
</div>
</div>
{% if hovedkontakt.title %}
<div class="mb-3">
<label class="small text-muted mb-1">Titel</label>
<div>{{ hovedkontakt.title }}</div>
</div>
{% endif %}
{% else %}
<p class="text-center text-muted mb-0">Ingen kontakt tilknyttet</p>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<!-- Afdeling Modal -->
<div class="modal fade" id="afdelingModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="background: var(--accent); color: white;">
<h5 class="modal-title">
<i class="bi bi-building me-2"></i>Rediger Afdeling
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label for="afdelingInput" class="form-label">Afdeling</label>
<input type="text"
class="form-control"
id="afdelingInput"
value="{{ customer.department if customer and customer.department else '' }}"
placeholder="Indtast afdeling">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" style="background: var(--accent); border: none;" onclick="updateAfdeling()">
Gem
</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Modals -->
{% if nextcloud_instance %}
<!-- Create User Modal -->
<div class="modal fade" id="ncCreateUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Nextcloud Bruger</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="ncCreateUserForm">
<div class="mb-3">
<label class="form-label">E-mail</label>
<input type="email" class="form-control" name="email" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label">Visningsnavn</label>
<input type="text" class="form-control" name="display_name" value="{{ hovedkontakt.first_name if hovedkontakt else '' }} {{ hovedkontakt.last_name if hovedkontakt else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label">Bruger ID (UID)</label>
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
<div class="form-text">Bruges til login. Oftest email.</div>
</div>
<div class="mb-3">
<label class="form-label">Grupper</label>
<input type="text" class="form-control" name="groups" value="Kunder" placeholder="f.eks. Kunder, Ekstern">
<div class="form-text">Komma-separeret liste af grupper</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="send_welcome" id="ncSendWelcome" checked>
<label class="form-check-label" for="ncSendWelcome">
Send velkomst-email med kode
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-success" onclick="ncCreateUser()">Opret Bruger</button>
</div>
</div>
</div>
</div>
<!-- Disable User Modal -->
<div class="modal fade" id="ncDisableUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger">Luk Nextcloud Bruger</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="ncDisableUserForm">
<div class="mb-3">
<label class="form-label">Bruger ID (UID)</label>
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
</div>
<div class="alert alert-warning small">
Brugeren vil ikke længere kunne logge ind, men data bevares.
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-danger" onclick="ncDisableUser()">Luk Bruger</button>
</div>
</div>
</div>
</div>
<!-- Reset Password Modal -->
<div class="modal fade" id="ncResetPasswordModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Reset Kodeord</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="ncResetPasswordForm">
<div class="mb-3">
<label class="form-label">Bruger ID (UID)</label>
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="send_email" id="ncSendResetEmail" checked>
<label class="form-check-label" for="ncSendResetEmail">
Send ny kode på email
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-warning" onclick="ncResetPassword()">Reset Kode</button>
</div>
</div>
</div>
</div>
<!-- Send Guide Modal -->
<div class="modal fade" id="ncSendGuideModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Send Guide</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="ncSendGuideForm">
<div class="mb-3">
<label class="form-label">Bruger ID (UID)</label>
<input type="text" class="form-control" name="uid" value="{{ hovedkontakt.email if hovedkontakt else '' }}" required>
</div>
<p class="small text-muted">Sender en email med start-guide til Nextcloud til brugerens registrerede mail.</p>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-info text-white" onclick="ncSendGuide()">Send Guide</button>
</div>
</div>
</div>
</div>
<script>
{% endif %}
</script>
<!-- Generic Search Modal -->
<div class="modal fade" id="entitySearchModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="entitySearchTitle">Søg</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="entitySearchInput" placeholder="Søg (min. 2 tegn)..." autocomplete="off">
</div>
<div class="text-center d-none" id="entitySearchSpinner">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div id="entitySearchResults" class="list-group list-group-flush" style="max-height: 300px; overflow-y: auto;">
<!-- Results go here -->
</div>
</div>
</div>
</div>
</div>
<!-- Create Related Case Modal -->
<div class="modal fade" id="createRelatedCaseModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret ny relateret sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createRelatedForm">
<div class="mb-3">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
</div>
<div class="mb-3">
<label class="form-label">Relationstype *</label>
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
<option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option>
<option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option>
<option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option>
<option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option>
</select>
</div>
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
<div class="alert alert-light border small">
<div class="fw-semibold mb-1">Sådan vælger du korrekt relation</div>
<div><strong>Relateret til</strong>: Samme emne/område, men ingen direkte afhængighed.</div>
<div><strong>Afledt af</strong>: Den nye sag opstår fordi den nuværende sag findes.</div>
<div><strong>Årsag til</strong>: Den nuværende sag opstår fordi den nye sag findes.</div>
<div><strong>Blokkerer</strong>: Løsning i én sag er nødvendig før den anden kan afsluttes.</div>
</div>
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="newCaseDescription" rows="3"></textarea>
</div>
<div class="alert alert-info small mb-0">
<i class="bi bi-info-circle me-1"></i>
Sagen oprettes for kunden: <strong>{{ case.customer_name }}</strong>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createRelatedCase()">Opret & Link</button>
</div>
</div>
</div>
</div>
<script>
let currentSearchType = null;
let searchDebounceIds = null;
const caseIds = {{ case.id }};
function openSearchModal(type) {
currentSearchType = type;
const titles = {
'hardware': 'Tilføj Hardware',
'location': 'Tilføj Lokation',
'contact': 'Tilføj Kontakt',
'customer': 'Tilføj Kunde'
};
document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg';
document.getElementById('entitySearchInput').value = '';
document.getElementById('entitySearchResults').innerHTML = '';
const modal = new bootstrap.Modal(document.getElementById('entitySearchModal'));
modal.show();
setTimeout(() => document.getElementById('entitySearchInput').focus(), 500);
}
document.getElementById('entitySearchInput').addEventListener('input', function(e) {
clearTimeout(searchDebounceIds);
const query = e.target.value.trim();
if (query.length < 2) {
document.getElementById('entitySearchResults').innerHTML = '';
return;
}
searchDebounceIds = setTimeout(() => performSearch(query), 300);
});
async function performSearch(query) {
document.getElementById('entitySearchSpinner').classList.remove('d-none');
document.getElementById('entitySearchResults').classList.add('d-none');
try {
let url = '';
if (currentSearchType === 'hardware') url = `/api/v1/search/hardware?q=${encodeURIComponent(query)}`;
else if (currentSearchType === 'location') url = `/api/v1/search/locations?q=${encodeURIComponent(query)}`;
else if (currentSearchType === 'contact') url = `/api/v1/search/contacts?q=${encodeURIComponent(query)}`;
else if (currentSearchType === 'customer') url = `/api/v1/search/customers?q=${encodeURIComponent(query)}`;
const res = await fetch(url);
if (!res.ok) throw new Error('Search failed');
const results = await res.json();
renderResults(results);
} catch (e) {
console.error(e);
document.getElementById('entitySearchResults').innerHTML = '<div class="text-danger text-center p-3">Fejl ved søgning</div>';
} finally {
document.getElementById('entitySearchSpinner').classList.add('d-none');
document.getElementById('entitySearchResults').classList.remove('d-none');
}
}
function renderResults(results) {
const container = document.getElementById('entitySearchResults');
if (results.length === 0) {
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
return;
}
container.innerHTML = results.map(item => {
let title = '', subtitle = '', icon = '', id = item.id;
if (currentSearchType === 'hardware') {
title = `${item.brand} ${item.model}`;
subtitle = `SN: ${item.serial_number}`;
icon = 'bi-laptop';
} else if (currentSearchType === 'location') {
title = item.name;
subtitle = `${item.address_street || ''} ${item.address_city || ''}`;
icon = 'bi-geo-alt';
} else if (currentSearchType === 'contact') {
title = `${item.first_name} ${item.last_name}`;
subtitle = item.email;
icon = 'bi-person';
} else if (currentSearchType === 'customer') {
title = item.name;
subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`;
icon = 'bi-building';
}
return `
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center" onclick="addEntity(${id})">
<div class="me-3 fs-4 text-muted"><i class="bi ${icon}"></i></div>
<div>
<div class="fw-bold">${title}</div>
<small class="text-muted">${subtitle}</small>
</div>
</button>
`;
}).join('');
}
async function addEntity(id) {
let url = '', body = {};
if (currentSearchType === 'hardware') {
url = `/api/v1/sag/${caseIds}/hardware`;
body = { hardware_id: id };
} else if (currentSearchType === 'location') {
url = `/api/v1/sag/${caseIds}/locations`;
body = { location_id: id };
} else if (currentSearchType === 'contact') {
url = `/api/v1/sag/${caseIds}/contacts`;
body = { contact_id: id };
} else if (currentSearchType === 'customer') {
url = `/api/v1/sag/${caseIds}/customers`;
body = { customer_id: id };
}
try {
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json();
alert("Fejl: " + (err.detail || 'Kunne ikke tilføje'));
return;
}
bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide();
window.location.reload();
} catch (e) {
alert("Fejl: " + e.message);
}
}
async function removeContact(caseId, contactId) {
if(!confirm("Fjern denne kontakt fra sagen?")) return;
try {
const res = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, { method: 'DELETE' });
if (res.ok) window.location.reload();
else alert("Fejl ved sletning");
} catch(e) { alert("Fejl: " + e.message); }
}
function openContactRoleModal(contactId, contactName, role, isPrimary) {
document.getElementById('contactRoleContactId').value = contactId;
document.getElementById('contactRoleName').textContent = contactName || '-';
document.getElementById('contactRoleInput').value = role || '';
document.getElementById('contactRolePrimary').checked = !!isPrimary;
const modal = new bootstrap.Modal(document.getElementById('contactRoleModal'));
modal.show();
}
async function saveContactRole() {
const contactId = document.getElementById('contactRoleContactId').value;
const role = document.getElementById('contactRoleInput').value.trim();
const isPrimary = document.getElementById('contactRolePrimary').checked;
try {
const res = await fetch(`/api/v1/sag/${caseIds}/contacts/${contactId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ role, is_primary: isPrimary })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere kontakt');
}
bootstrap.Modal.getInstance(document.getElementById('contactRoleModal')).hide();
window.location.reload();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function removeCustomer(caseId, customerId) {
if(!confirm("Fjern denne kunde fra sagen?")) return;
try {
const res = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, { method: 'DELETE' });
if (res.ok) window.location.reload();
else alert("Fejl ved sletning");
} catch(e) { alert("Fejl: " + e.message); }
}
async function updateDeferredUntil(value) {
try {
const res = await fetch(`/api/v1/sag/${caseIds}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ deferred_until: value || null })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere');
}
window.location.reload();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function updateDeadline(value) {
try {
const res = await fetch(`/api/v1/sag/${caseIds}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ deadline: value || null })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere deadline');
}
window.location.reload();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
function shiftDeadlineDays(days) {
const input = document.getElementById('deadlineInput');
const base = input.value ? new Date(input.value) : new Date();
base.setDate(base.getDate() + days);
input.value = base.toISOString().slice(0, 10);
updateDeadline(input.value);
}
function shiftDeadlineMonths(months) {
const input = document.getElementById('deadlineInput');
const base = input.value ? new Date(input.value) : new Date();
base.setMonth(base.getMonth() + months);
input.value = base.toISOString().slice(0, 10);
updateDeadline(input.value);
}
function openDeadlineModal() {
const modal = new bootstrap.Modal(document.getElementById('deadlineModal'));
modal.show();
}
function saveDeadlineAll() {
const input = document.getElementById('deadlineInput');
updateDeadline(input.value || null);
}
function clearDeadlineAll() {
const input = document.getElementById('deadlineInput');
input.value = '';
updateDeadline(null);
}
function setDeferredFromInput() {
const input = document.getElementById('deferredUntilInput');
updateDeferredUntil(input.value || null);
}
function shiftDeferredDays(days) {
const input = document.getElementById('deferredUntilInput');
const base = input.value ? new Date(input.value) : new Date();
base.setDate(base.getDate() + days);
input.value = base.toISOString().slice(0, 10);
updateDeferredUntil(input.value);
}
function shiftDeferredMonths(months) {
const input = document.getElementById('deferredUntilInput');
const base = input.value ? new Date(input.value) : new Date();
base.setMonth(base.getMonth() + months);
input.value = base.toISOString().slice(0, 10);
updateDeferredUntil(input.value);
}
function clearDeferredUntil() {
const input = document.getElementById('deferredUntilInput');
input.value = '';
updateDeferredUntil(null);
}
function openDeferredModal() {
const modal = new bootstrap.Modal(document.getElementById('deferredModal'));
modal.show();
}
async function updateDeferredCaseAndStatus(caseId, status) {
try {
const res = await fetch(`/api/v1/sag/${caseIds}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
deferred_until_case_id: caseId ? parseInt(caseId, 10) : null,
deferred_until_status: status || null
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere');
}
window.location.reload();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
function setDeferredCaseFromInputs() {
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
}
function clearDeferredCase() {
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
caseSelect.value = '';
statusSelect.value = '';
updateDeferredCaseAndStatus(null, null);
}
function saveDeferredAll() {
const input = document.getElementById('deferredUntilInput');
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
updateDeferredUntil(input.value || null);
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
}
function clearDeferredAll() {
const input = document.getElementById('deferredUntilInput');
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
input.value = '';
caseSelect.value = '';
statusSelect.value = '';
updateDeferredUntil(null);
updateDeferredCaseAndStatus(null, null);
}
function togglePipelineEdit(forceEdit = null) {
const view = document.getElementById('pipelineViewMode');
const edit = document.getElementById('pipelineEditMode');
const shouldEdit = forceEdit === null ? edit.classList.contains('d-none') : forceEdit;
if (shouldEdit) {
view.classList.add('d-none');
edit.classList.remove('d-none');
} else {
view.classList.remove('d-none');
edit.classList.add('d-none');
}
if (shouldEdit) {
ensurePipelineStagesLoaded();
}
}
async function ensurePipelineStagesLoaded() {
const select = document.getElementById('pipelineStageSelect');
if (!select) return;
if (select.options.length > 1) return;
try {
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
if (!response.ok) return;
const stages = await response.json();
if (!Array.isArray(stages) || stages.length === 0) return;
const existingValue = select.value || '';
select.innerHTML = '<option value="">Ikke sat</option>' +
stages.map((stage) => `<option value="${stage.id}">${stage.name}</option>`).join('');
if (existingValue) {
select.value = existingValue;
}
} catch (error) {
console.error('Could not load pipeline stages', error);
}
}
async function saveAssignment() {
const statusEl = document.getElementById('assignmentStatus');
const userValue = document.getElementById('assignmentUserSelect')?.value || '';
const groupValue = document.getElementById('assignmentGroupSelect')?.value || '';
const payload = {
ansvarlig_bruger_id: userValue ? parseInt(userValue, 10) : null,
assigned_group_id: groupValue ? parseInt(groupValue, 10) : null
};
if (statusEl) {
statusEl.textContent = 'Gemmer...';
}
try {
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
let message = 'Kunne ikke gemme tildeling';
try {
const data = await response.json();
message = data.detail || message;
} catch (err) {
// Keep default message
}
if (statusEl) {
statusEl.textContent = `${message}`;
}
return;
}
if (statusEl) {
statusEl.textContent = '✅ Tildeling gemt';
}
} catch (err) {
if (statusEl) {
statusEl.textContent = `${err.message}`;
}
}
}
async function savePipeline() {
const stageValue = document.getElementById('pipelineStageSelect').value;
const probabilityValue = document.getElementById('pipelineProbabilityInput').value;
const amountValue = document.getElementById('pipelineAmountInput').value;
const descriptionValue = document.getElementById('pipelineDescriptionInput').value;
const payload = {
stage_id: stageValue ? parseInt(stageValue, 10) : null,
probability: probabilityValue === '' ? null : parseInt(probabilityValue, 10),
amount: amountValue === '' ? null : parseFloat(amountValue),
description: descriptionValue === '' ? null : descriptionValue
};
try {
const response = await fetch(`/api/v1/sag/${caseId}/pipeline`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
let message = 'Kunne ikke opdatere pipeline';
try {
const err = await response.json();
message = err.detail || err.message || message;
} catch (_e) {
const text = await response.text();
if (text) message = text;
}
throw new Error(`${message} (HTTP ${response.status})`);
}
window.location.reload();
} catch (error) {
alert(`Fejl: ${error.message}`);
}
}
// ==========================================
// VIEW CONTROL (Tag-based)
// ==========================================
let modulePrefs = {};
let currentCaseView = 'Sag-detalje';
function moduleHasContent(el) {
const attr = el.getAttribute('data-has-content');
if (attr === 'true') return true;
if (attr === 'false') return false;
if (attr === 'unknown') return false;
if (el.querySelector('.person-card')) return true;
if (el.querySelector('.list-group-item')) return true;
return true;
}
function setModuleContentState(moduleKey, hasContent) {
const el = document.querySelector(`[data-module="${moduleKey}"]`);
if (!el) return;
el.setAttribute('data-has-content', hasContent ? 'true' : 'false');
applyViewLayout(currentCaseView);
}
function applyViewLayout(viewName) {
if (!viewName) return;
currentCaseView = viewName;
document.body.setAttribute('data-case-view', viewName);
const viewDefaults = {
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki'],
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar']
};
const defaultsByCaseType = caseTypeModuleDefaults[caseTypeKey];
const standardModules = Array.isArray(defaultsByCaseType) && defaultsByCaseType.length > 0
? defaultsByCaseType
: (viewDefaults[viewName] || []);
const standardModuleSet = new Set(standardModules);
document.querySelectorAll('[data-module]').forEach((el) => {
const moduleName = el.getAttribute('data-module');
const hasContent = moduleHasContent(el);
const isTimeModule = moduleName === 'time';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
if (isTimeModule) {
el.classList.remove('d-none');
el.classList.remove('module-empty-compact');
if (tabButton) tabButton.classList.remove('d-none');
return;
}
if (hasContent) {
el.classList.remove('d-none');
el.classList.remove('module-empty-compact');
if (tabButton) tabButton.classList.remove('d-none');
return;
}
if (pref === false) {
el.classList.add('d-none');
el.classList.remove('module-empty-compact');
if (tabButton) tabButton.classList.add('d-none');
return;
}
if (pref === true) {
el.classList.remove('d-none');
el.classList.toggle('module-empty-compact', shouldCompactWhenEmpty && !hasContent);
if (tabButton) tabButton.classList.remove('d-none');
return;
}
if (!hasContent) {
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');
}
} else {
el.classList.remove('d-none');
el.classList.remove('module-empty-compact');
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;
}, {});
modulePrefs.time = true;
} catch (e) {
console.error('Module prefs load failed', e);
}
}
async function loadCaseTypeModuleDefaultsSetting() {
try {
const res = await fetch('/api/v1/settings/case_type_module_defaults');
if (!res.ok) return;
const setting = await res.json();
const parsed = JSON.parse(setting.value || '{}');
if (parsed && typeof parsed === 'object') {
caseTypeModuleDefaults = Object.entries(parsed).reduce((acc, [key, value]) => {
acc[String(key || '').toLowerCase()] = Array.isArray(value) ? value : [];
return acc;
}, {});
} else {
caseTypeModuleDefaults = {};
}
} catch (e) {
console.error('Case type module defaults load failed', e);
caseTypeModuleDefaults = {};
}
}
async function openModuleControlModal() {
const list = document.getElementById('moduleControlList');
list.innerHTML = '<div class="text-muted small">Indlæser...</div>';
const modules = Array.from(document.querySelectorAll('[data-module]')).map(el => {
const key = el.getAttribute('data-module');
return { key, label: window.moduleDisplayNames[key] || key };
});
list.innerHTML = modules.map(m => {
const isTimeModule = m.key === 'time';
const checked = isTimeModule ? true : modulePrefs[m.key] !== false;
return `
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
${isTimeModule ? 'disabled' : ''}
onchange="toggleModulePref('${m.key}', this.checked)">
<label class="form-check-label" for="module_${m.key}">${m.label}${isTimeModule ? ' (altid synlig)' : ''}</label>
</div>
`;
}).join('');
const modal = new bootstrap.Modal(document.getElementById('moduleControlModal'));
modal.show();
}
async function toggleModulePref(moduleKey, isEnabled) {
if (moduleKey === 'time') {
modulePrefs.time = true;
applyViewFromTags();
return;
}
try {
const res = await fetch(`/api/v1/sag/${caseIds}/modules`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ module_key: moduleKey, is_enabled: isEnabled })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere modul');
}
modulePrefs[moduleKey] = isEnabled;
applyViewFromTags();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
// ==========================================
// FILES & EMAILS LOGIC
// ==========================================
// ---------------- 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>';
setModuleContentState('files', true);
}
} catch(e) {
console.error(e);
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
setModuleContentState('files', true);
}
}
function renderFiles(files) {
const container = document.getElementById('files-list');
if(!files || files.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen filer fundet...</div>';
setModuleContentState('files', false);
return;
}
setModuleContentState('files', true);
container.innerHTML = files.map(f => {
const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB';
return `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div class="ms-2 me-auto">
<div class="fw-bold text-truncate" style="max-width: 250px;">
<a href="javascript:void(0);" onclick="previewFile(${f.id}, '${f.filename.replace(/'/g, "\\'")}', '${f.content_type || ''}')" class="text-decoration-none text-dark">
<i class="bi bi-file-earmark me-1"></i> ${f.filename}
</a>
</div>
<small class="text-muted">${size}${new Date(f.created_at).toLocaleDateString()}</small>
</div>
<div class="d-flex gap-1">
<a href="${f.download_url}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
<i class="bi bi-download"></i>
</a>
<button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${f.id})" title="Slet">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
`;
}).join('');
}
async function handleFileUpload(fileList) {
if(!fileList || fileList.length === 0) return;
const formData = new FormData();
for (let i = 0; i < fileList.length; i++) {
formData.append("files", fileList[i]);
}
// Show loading
document.getElementById('files-list').innerHTML += '<div class="p-2 text-center text-muted fst-italic">Uploader...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseIds}/files`, {
method: 'POST',
body: formData
});
if(res.ok) {
loadSagFiles();
} else {
alert('Upload fejlede');
loadSagFiles(); // Reload to clear loading state
}
} catch(e) {
alert('Upload fejl: ' + e);
loadSagFiles();
}
}
async function deleteFile(fileId) {
if(!confirm("Slet denne fil?")) return;
try {
const res = await fetch(`/api/v1/sag/${caseIds}/files/${fileId}`, { method: 'DELETE' });
if(res.ok) loadSagFiles();
else alert("Kunne ikke slette fil");
} catch(e) { alert("Fejl: " + e); }
}
// File Preview
function previewFile(fileId, filename, contentType) {
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
const previewContent = document.getElementById('previewContent');
const fileNameEl = document.getElementById('previewFileName');
const downloadBtn = document.getElementById('previewDownloadBtn');
// Set filename and download link
fileNameEl.textContent = filename;
const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`;
downloadBtn.href = `${fileUrl}?download=true`;
downloadBtn.download = filename;
// Show loading spinner
previewContent.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
`;
modal.show();
// Determine file type and render preview
const ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(ext)) {
// Image preview
previewContent.innerHTML = `<img src="${fileUrl}" class="img-fluid" style="max-height: 80vh;" alt="${filename}">`;
} else if (ext === 'pdf') {
// PDF preview using iframe
previewContent.innerHTML = `<iframe src="${fileUrl}" class="w-100 h-100 border-0" style="min-height: 60vh;"></iframe>`;
} else if (['txt', 'log', 'md', 'json', 'xml', 'csv', 'html', 'css', 'js', 'py', 'sql'].includes(ext)) {
// Text file preview
fetch(fileUrl)
.then(res => res.text())
.then(text => {
previewContent.innerHTML = `<pre class="p-3 m-0 overflow-auto h-100" style="max-height: 80vh;"><code>${escapeHtml(text)}</code></pre>`;
})
.catch(err => {
previewContent.innerHTML = `<div class="alert alert-danger m-4">Kunne ikke indlæse fil: ${err}</div>`;
});
} else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) {
// Office documents - use Google Docs Viewer
const encodedUrl = encodeURIComponent(window.location.origin + fileUrl);
previewContent.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${encodedUrl}&embedded=true" class="w-100 h-100 border-0" style="min-height: 60vh;"></iframe>`;
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
// Video preview
previewContent.innerHTML = `
<video controls class="w-100" style="max-height: 80vh;">
<source src="${fileUrl}" type="video/${ext}">
Din browser understøtter ikke video afspilning.
</video>
`;
} else if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) {
// Audio preview
previewContent.innerHTML = `
<div class="p-5 text-center">
<i class="bi bi-music-note-beamed display-1 text-muted mb-4"></i>
<h5>${filename}</h5>
<audio controls class="w-100 mt-3">
<source src="${fileUrl}" type="audio/${ext}">
Din browser understøtter ikke audio afspilning.
</audio>
</div>
`;
} else {
// Unsupported file type
previewContent.innerHTML = `
<div class="p-5 text-center">
<i class="bi bi-file-earmark-x display-1 text-muted mb-4"></i>
<h5>Kan ikke vise forhåndsvisning for denne filtype</h5>
<p class="text-muted">${filename}</p>
<a href="${fileUrl}?download=true" class="btn btn-primary mt-3" download="${filename}">
<i class="bi bi-download me-2"></i> Download fil
</a>
</div>
`;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// File Drag & Drop
const fileDropZone = document.getElementById('fileDropZone');
if(fileDropZone) {
fileDropZone.addEventListener('dragover', e => { e.preventDefault(); fileDropZone.classList.add('bg-light-subtle'); });
fileDropZone.addEventListener('dragleave', e => { e.preventDefault(); fileDropZone.classList.remove('bg-light-subtle'); });
fileDropZone.addEventListener('drop', e => {
e.preventDefault();
fileDropZone.classList.remove('bg-light-subtle');
if(e.dataTransfer.files.length) handleFileUpload(e.dataTransfer.files);
});
}
// ---------------- EMAILS ----------------
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);
} else {
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
setModuleContentState('emails', true);
}
} catch(e) {
console.error(e);
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
setModuleContentState('emails', true);
}
}
function renderLinkedEmails(emails) {
const container = document.getElementById('linked-emails-list');
if(!emails || emails.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen linkede e-mails...</div>';
setModuleContentState('emails', false);
return;
}
setModuleContentState('emails', true);
const threadMap = new Map();
emails.forEach(e => {
const key = e.thread_key || `email-${e.id}`;
if(!threadMap.has(key)) threadMap.set(key, []);
threadMap.get(key).push(e);
});
const threads = Array.from(threadMap.values());
container.innerHTML = threads.map((threadEmails, threadIndex) => {
const labelEmail = threadEmails[0];
const messageCount = labelEmail.thread_message_count || threadEmails.length;
return `
<div class="list-group-item p-0">
<div class="px-3 py-2 border-bottom bg-light d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-secondary">Tråd ${threadIndex + 1}</span>
<span class="badge bg-primary-subtle text-primary-emphasis">${messageCount} beskeder</span>
</div>
${threadEmails.map(e => `
<div class="px-3 py-2 border-bottom">
<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('')}
</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>
<script>
const subscriptionCaseId = {{ case.id }};
let currentSubscription = null;
let subscriptionProducts = [];
let lastCreatedSubscriptionProductId = null;
function formatSubscriptionInterval(interval) {
const map = {
'daily': 'Daglig',
'biweekly': '14-dage',
'monthly': 'Maaned',
'quarterly': 'Kvartal',
'yearly': 'Aar'
};
return map[interval] || interval || '-';
}
function formatSubscriptionCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount || 0);
}
function formatSubscriptionDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK');
}
function setSubscriptionBadge(status) {
const badge = document.getElementById('subscriptionStatusBadge');
if (!badge) return;
const classes = {
'draft': 'bg-light text-dark',
'active': 'bg-success',
'paused': 'bg-warning',
'cancelled': 'bg-secondary'
};
const label = {
'draft': 'Kladde',
'active': 'Aktiv',
'paused': 'Pauset',
'cancelled': 'Opsagt'
};
badge.className = `badge ${classes[status] || 'bg-light text-dark'}`;
badge.textContent = label[status] || status || 'Ingen';
}
function showSubscriptionCreateForm() {
const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails');
if (empty) empty.classList.remove('d-none');
if (form) form.classList.remove('d-none');
if (details) details.classList.add('d-none');
setSubscriptionBadge(null);
const startDateInput = document.getElementById('subscriptionStartDateInput');
if (startDateInput && !startDateInput.value) {
startDateInput.value = new Date().toISOString().split('T')[0];
}
const body = document.getElementById('subscriptionLineItemsBody');
if (body) {
body.innerHTML = `
<tr>
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Managed Backup"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
</tr>
`;
}
populateSubscriptionProductSelects();
updateSubscriptionLineTotals();
}
function populateSubscriptionProductSelects() {
const selects = document.querySelectorAll('.subscriptionProductSelect');
selects.forEach(select => {
const currentValue = select.value;
select.innerHTML = '<option value="">Vælg produkt</option>';
subscriptionProducts.forEach(product => {
const option = document.createElement('option');
option.value = product.id;
option.textContent = product.name;
option.dataset.salesPrice = product.sales_price ?? '';
option.dataset.description = product.short_description ?? '';
select.appendChild(option);
});
if (currentValue) {
select.value = currentValue;
} else if (lastCreatedSubscriptionProductId) {
select.value = String(lastCreatedSubscriptionProductId);
}
});
lastCreatedSubscriptionProductId = null;
}
function applySubscriptionProduct(select) {
const row = select.closest('tr');
if (!row) return;
const descriptionInput = row.querySelector('input[type="text"]');
const unitPriceInput = row.querySelectorAll('input[type="number"]')[1];
const selected = select.options[select.selectedIndex];
if (!selected) return;
const description = selected.dataset.description || selected.textContent || '';
const salesPrice = selected.dataset.salesPrice;
if (descriptionInput && !descriptionInput.value.trim()) {
descriptionInput.value = description;
}
if (unitPriceInput && salesPrice !== '') {
unitPriceInput.value = salesPrice;
}
updateSubscriptionLineTotals();
}
function addSubscriptionLine() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return;
const row = document.createElement('tr');
row.innerHTML = `
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
`;
body.appendChild(row);
populateSubscriptionProductSelects();
updateSubscriptionLineTotals();
}
function removeSubscriptionLine(button) {
const row = button.closest('tr');
const body = document.getElementById('subscriptionLineItemsBody');
if (!row || !body) return;
if (body.children.length <= 1) {
row.querySelectorAll('input').forEach(input => {
input.value = input.type === 'number' ? 0 : '';
});
} else {
row.remove();
}
updateSubscriptionLineTotals();
}
function updateSubscriptionLineTotals() {
const body = document.getElementById('subscriptionLineItemsBody');
const totalEl = document.getElementById('subscriptionLinesTotal');
if (!body || !totalEl) return;
let total = 0;
Array.from(body.querySelectorAll('tr')).forEach(row => {
const inputs = row.querySelectorAll('input');
const description = inputs[0]?.value || '';
const qty = parseFloat(inputs[1]?.value || 0);
const unit = parseFloat(inputs[2]?.value || 0);
const lineTotal = (qty > 0 ? qty : 0) * (unit > 0 ? unit : 0);
total += lineTotal;
const lineTotalEl = row.querySelector('.subscriptionLineTotal');
if (lineTotalEl) {
lineTotalEl.textContent = formatSubscriptionCurrency(lineTotal);
}
if (!description && qty === 0 && unit === 0) {
if (lineTotalEl) {
lineTotalEl.textContent = formatSubscriptionCurrency(0);
}
}
});
totalEl.textContent = formatSubscriptionCurrency(total);
}
function collectSubscriptionLineItems() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return [];
const items = [];
Array.from(body.querySelectorAll('tr')).forEach(row => {
const productSelect = row.querySelector('.subscriptionProductSelect');
const inputs = row.querySelectorAll('input');
const description = (inputs[0]?.value || '').trim();
const quantity = parseFloat(inputs[1]?.value || 0);
const unitPrice = parseFloat(inputs[2]?.value || 0);
if (!description && quantity === 0 && unitPrice === 0) {
return;
}
items.push({
product_id: productSelect && productSelect.value ? parseInt(productSelect.value, 10) : null,
description,
quantity,
unit_price: unitPrice
});
});
return items;
}
async function loadSubscriptionProducts() {
try {
const res = await fetch('/api/v1/products');
if (!res.ok) {
throw new Error('Kunne ikke hente produkter');
}
subscriptionProducts = await res.json();
} catch (e) {
console.error('Error loading products:', e);
subscriptionProducts = [];
}
populateSubscriptionProductSelects();
}
function openSubscriptionProductModal() {
const form = document.getElementById('subscriptionProductForm');
if (form) form.reset();
new bootstrap.Modal(document.getElementById('subscriptionProductModal')).show();
}
async function createSubscriptionProduct() {
const payload = {
name: document.getElementById('subscriptionProductName').value.trim(),
type: document.getElementById('subscriptionProductType').value.trim() || null,
status: document.getElementById('subscriptionProductStatus').value,
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null
};
if (!payload.name) {
alert('Navn er paakraevet');
return;
}
const res = await fetch('/api/v1/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const error = await res.json();
alert(error.detail || 'Kunne ikke oprette produkt');
return;
}
const product = await res.json();
lastCreatedSubscriptionProductId = product.id;
bootstrap.Modal.getInstance(document.getElementById('subscriptionProductModal')).hide();
await loadSubscriptionProducts();
updateSubscriptionLineTotals();
}
function renderSubscription(subscription) {
currentSubscription = subscription;
const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails');
if (empty) empty.classList.add('d-none');
if (form) form.classList.add('d-none');
if (details) details.classList.remove('d-none');
document.getElementById('subscriptionNumber').textContent = subscription.subscription_number || `#${subscription.id}`;
document.getElementById('subscriptionProduct').textContent = subscription.product_name || '-';
document.getElementById('subscriptionInterval').textContent = formatSubscriptionInterval(subscription.billing_interval);
document.getElementById('subscriptionPrice').textContent = formatSubscriptionCurrency(subscription.price);
document.getElementById('subscriptionStartDate').textContent = formatSubscriptionDate(subscription.start_date);
document.getElementById('subscriptionStatusText').textContent = subscription.status || '-';
// New fields
const periodStartEl = document.getElementById('subscriptionPeriodStart');
const nextInvoiceEl = document.getElementById('subscriptionNextInvoice');
if (periodStartEl) {
periodStartEl.textContent = subscription.period_start ? formatSubscriptionDate(subscription.period_start) : '-';
}
if (nextInvoiceEl) {
const nextDate = subscription.next_invoice_date ? formatSubscriptionDate(subscription.next_invoice_date) : '-';
nextInvoiceEl.textContent = nextDate;
// Highlight if invoice is due soon
if (subscription.next_invoice_date) {
const daysUntil = Math.ceil((new Date(subscription.next_invoice_date) - new Date()) / (1000 * 60 * 60 * 24));
if (daysUntil <= 7 && daysUntil >= 0) {
nextInvoiceEl.innerHTML = `${nextDate} <span class="badge bg-warning text-dark">Om ${daysUntil} dage</span>`;
}
}
}
setSubscriptionBadge(subscription.status);
const itemsBody = document.getElementById('subscriptionItemsBody');
const itemsTotal = document.getElementById('subscriptionItemsTotal');
if (itemsBody) {
const items = subscription.line_items || [];
if (!items.length) {
itemsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Ingen linjer</td></tr>';
} else {
itemsBody.innerHTML = items.map(item => `
<tr>
<td>${item.product_name || '-'}</td>
<td>${item.description}</td>
<td class="text-end">${parseFloat(item.quantity).toFixed(2)}</td>
<td class="text-end">${formatSubscriptionCurrency(item.unit_price)}</td>
<td class="text-end">${formatSubscriptionCurrency(item.line_total)}</td>
</tr>
`).join('');
}
}
if (itemsTotal) {
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
}
const actions = document.getElementById('subscriptionActions');
if (!actions) return;
const buttons = [];
if (subscription.status === 'draft' || subscription.status === 'paused') {
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
}
if (subscription.status === 'active') {
buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`);
}
if (subscription.status !== 'cancelled') {
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
}
actions.innerHTML = buttons.join(' ');
}
async function loadSubscriptionForCase() {
try {
const res = await fetch(`/api/v1/sag-subscriptions/by-sag/${subscriptionCaseId}`);
if (res.status === 404) {
showSubscriptionCreateForm();
setModuleContentState('subscription', false);
return;
}
if (!res.ok) {
throw new Error('Kunne ikke hente abonnement');
}
const subscription = await res.json();
renderSubscription(subscription);
setModuleContentState('subscription', true);
} catch (e) {
console.error('Error loading subscription:', e);
showSubscriptionCreateForm();
setModuleContentState('subscription', true);
}
}
async function createSubscription() {
const billingInterval = document.getElementById('subscriptionIntervalInput').value;
const billingDay = parseInt(document.getElementById('subscriptionBillingDayInput').value, 10);
const startDate = document.getElementById('subscriptionStartDateInput').value;
const notes = document.getElementById('subscriptionNotesInput').value.trim();
const lineItems = collectSubscriptionLineItems();
if (!billingInterval || !billingDay || !startDate) {
alert('Udfyld venligst alle paakraevet felter');
return;
}
if (!lineItems.length) {
alert('Du skal angive mindst en varelinje');
return;
}
try {
const res = await fetch('/api/v1/sag-subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: subscriptionCaseId,
billing_interval: billingInterval,
billing_day: billingDay,
start_date: startDate,
notes: notes || null,
line_items: lineItems
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
const subscription = await res.json();
renderSubscription(subscription);
} catch (e) {
alert(e.message || e);
}
}
async function updateSubscriptionStatus(status) {
if (!currentSubscription) return;
if (status === 'cancelled' && !confirm('Er du sikker paa, at abonnementet skal opsiges?')) {
return;
}
try {
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke opdatere status');
}
const updated = await res.json();
renderSubscription(updated);
} catch (e) {
alert(e.message || e);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadSubscriptionProducts();
loadSubscriptionForCase();
});
// === Quick Time Entry Functions (for inline time tracking) ===
function toggleQuickTimeForm() {
const container = document.getElementById('quickTimeFormContainer');
if (container) {
container.classList.remove('d-none');
}
}
// Make function globally available for onclick handler
window.toggleQuickTimeForm = toggleQuickTimeForm;
async function quickAddTime(event) {
event.preventDefault();
const form = document.getElementById('quickAddTimeForm');
const formData = new FormData(form);
// Parse hours and minutes
const hours = parseInt(formData.get('hours')) || 0;
const minutes = parseInt(formData.get('minutes')) || 0;
const totalHours = hours + (minutes / 60);
if (totalHours === 0) {
alert('Angiv venligst timer eller minutter');
return;
}
const billingSelect = document.getElementById('quickTimeBillingMethod');
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
let prepaidCardId = null;
let fixedPriceAgreementId = null;
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
if (billingMethod.startsWith('fpa_')) {
fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'fixed_price';
}
const isInternal = billingMethod === 'internal';
// Build payload
const payload = {
sag_id: {{ case.id }},
worked_date: formData.get('date'),
original_hours: totalHours,
description: formData.get('description'),
billing_method: billingMethod,
is_internal: isInternal
};
if (prepaidCardId) {
payload.prepaid_card_id = prepaidCardId;
}
if (fixedPriceAgreementId) {
payload.fixed_price_agreement_id = fixedPriceAgreementId;
}
try {
const response = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme tidsregistrering');
}
// Success - reload page to show new entry
window.location.reload();
} catch (error) {
alert('Fejl: ' + error.message);
console.error('Quick add time error:', error);
}
}
// Set today's date as default for quick time form
document.addEventListener('DOMContentLoaded', function() {
const dateInput = document.getElementById('quickTimeDate');
if (dateInput && !dateInput.value) {
const today = new Date().toISOString().split('T')[0];
dateInput.value = today;
}
});
</script>
</div>
{% endblock %}