bmc_hub/app/modules/sag/templates/detail.html
Christian e4b9091a1b feat: Implement fixed-price agreements frontend views and related templates
- Added views for listing fixed-price agreements, displaying agreement details, and a reporting dashboard.
- Created HTML templates for listing, detailing, and reporting on fixed-price agreements.
- Introduced API endpoint to fetch active customers for agreement creation.
- Added migration scripts for creating necessary database tables and views for fixed-price agreements, billing periods, and reporting.
- Implemented triggers for auto-generating agreement numbers and updating timestamps.
- Enhanced ticket management with archived ticket views and filtering capabilities.
2026-02-08 01:45:00 +01:00

4354 lines
202 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);
}
.relation-tree {
list-style: none;
margin: 0;
padding-left: 0;
}
.relation-node {
position: relative;
padding-left: 1.2rem;
}
.relation-node:before {
content: "";
position: absolute;
left: 0.45rem;
top: 0.2rem;
bottom: 0.2rem;
width: 1px;
background: rgba(15, 76, 117, 0.25);
}
.relation-node:after {
content: "";
position: absolute;
left: 0.45rem;
top: 1.05rem;
width: 0.6rem;
height: 1px;
background: rgba(15, 76, 117, 0.35);
}
.relation-node:last-child:before {
bottom: 1.05rem;
}
.relation-children {
margin-left: 0.55rem;
padding-left: 0.65rem;
border-left: 1px dashed rgba(15, 76, 117, 0.25);
}
.relation-node-card {
background: rgba(15, 76, 117, 0.03);
border: 1px solid rgba(15, 76, 117, 0.12);
}
.relation-type-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
padding: 0.15rem 0.45rem;
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 -->
<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 gap-3" 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" style="border-left: 1px solid rgba(0,0,0,0.1); padding-left: 0.75rem;">
<strong style="color: var(--accent); margin-right: 0.4rem;">Kunde:</strong>
<span>{{ customer.name if customer else 'Ingen kunde' }}</span>
</div>
<div class="d-flex align-items-center" style="border-left: 1px solid rgba(0,0,0,0.1); padding-left: 0.75rem;">
<strong style="color: var(--accent); margin-right: 0.4rem;">Hovedkontakt:</strong>
{% if hovedkontakt %}
<span style="cursor: pointer; text-decoration: underline; color: var(--accent);"
onclick="showKontaktModal()"
title="Klik for at se kontaktinfo">
{{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }}
</span>
{% else %}
<span>Ingen kontakt</span>
{% endif %}
</div>
<div class="d-flex align-items-center" style="border-left: 1px solid rgba(0,0,0,0.1); padding-left: 0.75rem;">
<strong style="color: var(--accent); margin-right: 0.4rem;">Afdeling:</strong>
<span style="cursor: pointer; text-decoration: underline; color: var(--accent);"
onclick="showAfdelingModal()"
title="Klik for at ændre afdeling">
{{ customer.department if customer and customer.department else 'N/A' }}
</span>
</div>
<div class="d-flex align-items-center" style="border-left: 1px solid rgba(0,0,0,0.1); padding-left: 0.75rem;">
<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>
<div class="d-flex align-items-center" style="border-left: 1px solid rgba(0,0,0,0.1); padding-left: 0.75rem;">
<strong style="color: var(--accent); margin-right: 0.4rem;">Opdateret:</strong>
<span>{{ case.updated_at.strftime('%d/%m-%Y') if case.updated_at else 'N/A' }}</span>
</div>
<div class="d-flex align-items-center" style="border-left: 1px solid rgba(0,0,0,0.1); padding-left: 0.75rem;">
<strong style="color: var(--accent); margin-right: 0.4rem;">Deadline:</strong>
<span>{{ case.deadline.strftime('%d/%m-%Y') if case.deadline else 'Ikke sat' }}</span>
</div>
</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="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>Reminders
</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.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">
<div class="case-summary-grid">
<div class="summary-item">
<div class="summary-label">Kunde</div>
<div class="summary-value">{{ customer.name if customer else 'Ingen kunde' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Hovedkontakt</div>
<div class="summary-value">
{% if hovedkontakt %}
<span class="summary-link" onclick="showKontaktModal()" title="Klik for at se kontaktinfo">
{{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }}
</span>
{% else %}
-
{% endif %}
</div>
</div>
<div class="summary-item">
<div class="summary-label">Afdeling</div>
<div class="summary-value">
<span class="summary-link" onclick="showAfdelingModal()" title="Klik for at ændre afdeling">
{{ customer.department if customer and customer.department else 'N/A' }}
</span>
</div>
</div>
<div class="summary-item">
<div class="summary-label">Oprettet</div>
<div class="summary-value">{{ case.created_at|string|truncate(19, True, '') if case.created_at else 'Ikke sat' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Opdateret</div>
<div class="summary-value">{{ case.updated_at.strftime('%d/%m-%Y') if case.updated_at else 'N/A' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Deadline</div>
<div class="summary-value">{{ case.deadline.strftime('%d/%m-%Y') if case.deadline else 'Ikke sat' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">Udsat start</div>
<div class="summary-value">
<div class="summary-inline">
<span class="summary-pill">
{% if case.deferred_until %}
{{ case.deferred_until.strftime('%d/%m-%Y') }}
{% else %}
Ikke sat
{% endif %}
</span>
<span class="summary-pill">
{% if case.deferred_until_case_id %}
Sag #{{ case.deferred_until_case_id }}
{% else %}
Ingen sag
{% endif %}
</span>
<span class="summary-pill">
{{ case.deferred_until_status or 'Ingen status' }}
</span>
<button class="btn btn-sm btn-outline-primary" onclick="openDeferredModal()">
Rediger
</button>
</div>
</div>
</div>
</div>
<div class="case-summary-desc">
<div class="summary-label">Beskrivelse</div>
<div class="summary-value">{{ case.beskrivelse or 'Ingen beskrivelse' }}</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">
<h6 class="mb-0" style="color: var(--accent);">🔗 Relationer</h6>
<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-light-subtle border border-primary-subtle' if node.is_current else '' }} rounded px-2 py-1" style="min-height: 32px;">
{% if node.relation_type %}
<span class="relation-type-badge">{{ node.relation_type }}</span>
{% endif %}
<a href="/sag/{{ node.case.id }}" class="text-decoration-none {{ 'text-dark' if node.is_current else 'text-muted' }}">
<span class="badge bg-secondary me-1" style="font-size: 0.7rem;">#{{ node.case.id }}</span>
{{ node.case.titel }}
</a>
<span class="status-dot status-{{ node.case.status }} ms-2" title="{{ node.case.status }}"></span>
{% if node.relation_id %}
<button onclick="deleteRelation({{ node.relation_id }})" class="btn btn-sm text-danger border-0 ms-1 p-0 opacity-50 hover-opacity-100" title="Fjern relation">
<i class="bi bi-x-circle-fill"></i>
</button>
{% 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 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 Emails</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 email..." 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 emails 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()">
<option value="">Vælg hvordan sagerne er relateret...</option>
<option value="relateret">🔗 Relateret - Generel relation</option>
<option value="afhænger af">⏳ Afhænger af - Denne sag venter på den anden</option>
<option value="blokkerer">🚫 Blokkerer - Denne sag blokerer den anden</option>
<option value="duplikat">📋 Duplikat - Sagerne er den samme</option>
<option value="forårsaget af">🔄 Forårsaget af - Denne sag er konsekvens af den anden</option>
<option value="følger op på">📌 Følger op på - Fortsættelse af tidligere sag</option>
</select>
</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">Email</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>
<!-- 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 }};
let contactSearchTimeout;
let customerSearchTimeout;
let relationSearchTimeout;
let selectedRelationCaseId = null;
// 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();
// Render Global Tags
if (window.renderEntityTags) {
window.renderEntityTags('case', {{ case.id }}, 'case-tags');
}
loadModulePrefs().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();
// 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();
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').textContent = currentContactInfo.phone || '-';
document.getElementById('contactInfoMobile').textContent = currentContactInfo.mobile || '-';
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 openContactRoleFromInfo() {
if (!currentContactInfo) return;
contactInfoModal.hide();
openContactRoleModal(
currentContactInfo.id,
currentContactInfo.name,
currentContactInfo.role || 'Kontakt',
currentContactInfo.isPrimary
);
}
function showCreateRelatedModal() {
createRelatedCaseModalInstance.show();
}
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>';
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('')}
`;
} 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>';
}
}
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>';
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.id})" title="Slet">
</button>
</div>
`).join('')}
`;
} 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>';
}
}
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 {
await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
loadCaseLocations();
} catch (e) {
alert("Fejl ved sletning");
}
}
// 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>
</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>Title</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>Email</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="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6>
<button class="btn btn-sm btn-outline-primary" onclick="showAddTimeModal()">
<i class="bi bi-plus-lg me-1"></i>Registrer Tid
</button>
</div>
<div class="card-body p-0" style="max-height: 180px; overflow: auto;">
<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>Beskrivelse</th>
<th>Bruger</th>
<th>Timer</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td class="ps-3">{{ entry.worked_date }}</td>
<td>{{ entry.description or '-' }}</td>
<td>{{ entry.user_name }}</td>
<td class="fw-bold">{{ entry.original_hours }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center py-3 text-muted">Ingen tid registreret</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="border-top px-3 py-2 small text-muted">
<div class="fw-semibold text-primary mb-1"><i class="bi bi-credit-card me-1"></i>Klippekort</div>
{% if prepaid_cards %}
<div class="d-flex flex-column gap-1">
{% for card in prepaid_cards %}
<div class="d-flex justify-content-between">
<span>#{{ card.card_number or card.id }}</span>
<span>{{ '%.2f' % card.remaining_hours }}t</span>
</div>
{% endfor %}
</div>
{% else %}
<div>Ingen aktive klippekort</div>
{% endif %}
</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>
<!-- 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>
<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-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">Email</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 || []);
} 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>';
}
}
}
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');
}
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() {
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_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>';
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>';
}
}
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>';
return;
}
const triggerLabels = {
time_based: 'Tidspunkt',
status_change: 'Status ændring',
deadline_approaching: '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">
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('');
}
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 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,
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();
} 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();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
document.addEventListener('DOMContentLoaded', function() {
updateReminderTriggerFields();
updateReminderRecurrenceFields();
loadReminders();
});
</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">Email</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 %}
<a href="tel:{{ hovedkontakt.mobile }}" style="color: var(--accent);">
<i class="bi bi-phone me-1"></i>{{ hovedkontakt.mobile }}
</a>
{% 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">Email</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">
<option value="Relateret til">Relateret til</option>
<option value="Afledt af">Afledt af</option>
<option value="Årsag til">Årsag til</option>
</select>
</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);
}
}
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);
}
// ==========================================
// VIEW CONTROL (Tag-based)
// ==========================================
let modulePrefs = {};
function moduleHasContent(el) {
const attr = el.getAttribute('data-has-content');
if (attr === 'true') return true;
if (attr === 'false') return false;
if (attr === 'unknown') return true;
if (el.querySelector('.person-card')) return true;
if (el.querySelector('.list-group-item')) return true;
return true;
}
function applyViewLayout(viewName) {
if (!viewName) return;
document.body.setAttribute('data-case-view', viewName);
const viewDefaults = {
'Pipeline': ['relations', 'sales', 'time'],
'Kundevisning': ['customers', 'contacts', 'locations'],
'Sag-detalje': ['hardware', 'locations', 'contacts', 'customers', 'relations', 'files', 'emails', 'solution', 'time', 'sales', 'reminders']
};
const standardModules = viewDefaults[viewName] || [];
document.querySelectorAll('[data-module]').forEach((el) => {
const moduleName = el.getAttribute('data-module');
const hasContent = moduleHasContent(el);
const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
if (pref === false) {
el.classList.add('d-none');
if (tabButton) tabButton.classList.add('d-none');
return;
}
if (pref === true) {
el.classList.remove('d-none');
if (tabButton) tabButton.classList.remove('d-none');
return;
}
if (!standardModules.includes(moduleName) && !hasContent) {
el.classList.add('d-none');
if (tabButton) tabButton.classList.add('d-none');
} else {
el.classList.remove('d-none');
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;
}, {});
} catch (e) {
console.error('Module prefs load failed', e);
}
}
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: key };
});
list.innerHTML = modules.map(m => {
const checked = modulePrefs[m.key] !== false;
return `
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="module_${m.key}" ${checked ? 'checked' : ''}
onchange="toggleModulePref('${m.key}', this.checked)">
<label class="form-check-label" for="module_${m.key}">${m.label}</label>
</div>
`;
}).join('');
const modal = new bootstrap.Modal(document.getElementById('moduleControlModal'));
modal.show();
}
async function toggleModulePref(moduleKey, isEnabled) {
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>';
}
} catch(e) {
console.error(e);
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
}
}
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>';
return;
}
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);
}
} catch(e) { console.error(e); }
}
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 emails...</div>';
return;
}
container.innerHTML = emails.map(e => `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="text-truncate">
<i class="bi bi-envelope text-primary me-1"></i>
<strong>${e.subject || '(Ingen emne)'}</strong>
<div class="small text-muted text-truncate">${e.sender_email}</div>
</div>
<button class="btn btn-sm btn-link text-danger p-0 ms-2" onclick="unlinkEmail(${e.id})">
<i class="bi bi-link-45deg" style="text-decoration: line-through;"></i>
</button>
</div>
</div>
`).join('');
}
async function unlinkEmail(emailId) {
if(!confirm("Fjern link til denne email?")) return;
try {
const res = await fetch(`/api/v1/sag/${caseIds}/email-links/${emailId}`, { method: 'DELETE' });
if(res.ok) loadLinkedEmails();
} catch(e) { alert(e); }
}
// Email Search
const emailSearchInput = document.getElementById('emailSearchInput');
const emailSearchResults = document.getElementById('emailSearchResults');
let emailDebounce = null;
if(emailSearchInput) {
emailSearchInput.addEventListener('input', e => {
clearTimeout(emailDebounce);
const q = e.target.value.trim();
if(q.length < 2) {
emailSearchResults.style.display = 'none';
return;
}
emailDebounce = setTimeout(() => searchEmails(q), 300);
});
// Hide on outside click
document.addEventListener('click', e => {
if(!emailSearchInput.contains(e.target) && !emailSearchResults.contains(e.target)) {
emailSearchResults.style.display = 'none';
}
});
}
async function searchEmails(query) {
try {
const res = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
if(res.ok) {
const emails = await res.json();
renderEmailSuggestions(emails);
emailSearchResults.style.display = 'block';
}
} catch(e) { console.error(e); }
}
function renderEmailSuggestions(emails) {
if(!emails.length) {
emailSearchResults.innerHTML = '<div class="list-group-item text-muted">Ingen fundet</div>';
return;
}
emailSearchResults.innerHTML = emails.map(e => `
<button class="list-group-item list-group-item-action" onclick="linkEmail(${e.id})">
<div class="fw-bold text-truncate">${e.subject}</div>
<div class="small text-muted">${e.sender_email}</div>
</button>
`).join('');
}
async function linkEmail(emailId) {
emailSearchInput.value = '';
emailSearchResults.style.display = 'none';
try {
const res = await fetch(`/api/v1/sag/${caseIds}/email-links`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email_id: emailId})
});
if(res.ok) loadLinkedEmails();
else alert("Kunne ikke linke email");
} catch(e) { alert(e); }
}
// Email Import Drag & Drop (.msg / .eml)
const emailDropZone = document.getElementById('emailDropZone');
if(emailDropZone) {
emailDropZone.addEventListener('dragover', e => { e.preventDefault(); emailDropZone.classList.add('bg-warning-subtle'); });
emailDropZone.addEventListener('dragleave', e => { e.preventDefault(); emailDropZone.classList.remove('bg-warning-subtle'); });
emailDropZone.addEventListener('drop', e => {
e.preventDefault();
emailDropZone.classList.remove('bg-warning-subtle');
const files = e.dataTransfer.files;
if(files.length) uploadEmailFile(files[0]);
});
}
async function uploadEmailFile(file) {
const formData = new FormData();
formData.append('file', file);
// Show busy indicator
emailDropZone.style.opacity = '0.5';
try {
const res = await fetch(`/api/v1/sag/${caseIds}/upload-email`, {
method: 'POST',
body: formData
});
if(res.ok) {
loadLinkedEmails();
} else {
alert('Import fejlede');
}
} catch(e) { alert(e); }
finally {
emailDropZone.style.opacity = '1';
}
}
// Load content on start
document.addEventListener('DOMContentLoaded', () => {
loadSagFiles();
loadLinkedEmails();
});
</script>
</div>
{% endblock %}