bmc_hub/app/modules/sag/templates/detail.html
Christian 56d6d45aa2 feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00

3182 lines
152 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}{{ case.titel }} - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--accent);
text-decoration: none;
margin-bottom: 1.5rem;
transition: all 0.2s;
}
.back-link:hover {
gap: 1rem;
}
.card {
border: none;
border-radius: 12px;
margin-bottom: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.1);
}
.card-header {
background: var(--accent-light);
border-bottom: 1px solid rgba(0,0,0,0.1);
padding: 1.2rem;
border-radius: 12px 12px 0 0;
}
.card-body {
padding: 1.5rem;
}
.card-footer {
background: transparent;
border-top: 1px solid rgba(0,0,0,0.1);
padding: 1rem;
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
border-radius: 6px;
}
.relation-card {
padding: 1rem 0;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.relation-card:last-child {
border-bottom: none;
}
.relation-type {
display: inline-block;
background: var(--accent-light);
color: var(--accent);
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.relation-link {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.relation-link:hover {
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;
}
.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>
</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">
<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="time-tab" data-bs-toggle="tab" data-bs-target="#time" type="button" role="tab">
<i class="bi bi-clock-history me-2"></i>Tid & Fakturering
</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">
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
</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">
<!-- ROW 1: Main Info + Tags + Hardware -->
<div class="row mb-3">
<!-- Main Case Info -->
<div class="col-md-6 mb-3">
<div class="card h-100 d-flex flex-column">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="mb-0 fs-4" style="color: var(--accent);">{{ case.titel }}</h3>
<div>
<a href="/sag/{{ case.id }}/edit" class="btn btn-sm btn-outline-primary me-2">
<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 flex-grow-1 overflow-auto">
<div class="info-row">
<span class="info-label">Status</span>
<span class="tag">{{ case.status }}</span>
</div>
<div class="info-row">
<span class="info-label">Type</span>
<span class="tag">{{ case.type or 'ticket' }}</span>
</div>
<div class="info-row">
<span class="info-label">Beskrivelse</span>
<span class="info-value">{{ case.beskrivelse or 'Ingen beskrivelse' }}</span>
</div>
<div class="info-row">
<span class="info-label">Oprettet</span>
<span class="info-value">{{ case.created_at|string|truncate(19, True, '') if case.created_at else 'Ikke sat' }}</span>
</div>
{% if case.deadline %}
<div class="info-row">
<span class="info-label">Deadline</span>
<span class="info-value">{{ case.deadline|string|truncate(19, True, '') if case.deadline else 'Ikke sat' }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Hardware -->
<div class="col-md-6 mb-3">
<div class="card d-flex flex-column h-100">
<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: 400px;">
<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>
</div>
<!-- ROW 2: Locations + Contacts + Customers -->
<div class="row mb-3">
<div class="col-md-4 mb-3">
<div class="card h-100 d-flex flex-column">
<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: 300px;">
<div class="list-group list-group-flush" id="locations-list">
<div class="p-3 text-center text-muted">Henter lokationer...</div>
</div>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100 d-flex flex-column">
<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: 300px;">
{% if contacts %}
{% for contact in contacts %}
<div class="person-card">
<div class="person-info">
<strong>{{ contact.contact_name }}</strong>
<small>{{ contact.role }}</small>
{% if contact.contact_email %}
<small>{{ contact.contact_email }}</small>
{% endif %}
</div>
<button onclick="removeContact({{ case.id }}, {{ contact.contact_id }})" class="btn btn-sm btn-delete"></button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kontakter</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100 d-flex flex-column">
<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: 300px;">
{% if customers %}
{% for customer in customers %}
<div class="person-card">
<div class="person-info">
<strong><a href="/customers/{{ customer.customer_id }}">{{ customer.customer_name }}</a></strong>
<small>{{ customer.role }}</small>
{% if customer.customer_email %}
<small>{{ customer.customer_email }}</small>
{% endif %}
</div>
<button onclick="removeCustomer({{ case.id }}, {{ customer.customer_id }})" class="btn btn-sm btn-delete"></button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen kunder</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ROW 3: Relations + SAG Compatibility -->
<div class="row mb-3">
<div class="col-md-8 mb-3">
<div class="card h-100 d-flex flex-column">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🔗 Relaterede sager</h6>
<div>
<button class="btn btn-sm btn-outline-success me-1" onclick="showCreateRelatedModal()" title="Opret ny relateret sag">
<i class="bi bi-file-earmark-plus"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()" title="Link eksisterende sag">
<i class="bi bi-link-45deg"></i>
</button>
</div>
</div>
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
{% macro render_tree(nodes) %}
<ul class="list-group list-group-flush" style="padding-left: 1.5rem; margin-bottom: 0;">
{% for node in nodes %}
<li class="list-group-item border-0 p-0 bg-transparent">
<div class="d-flex align-items-center py-1">
<small class="text-muted me-2" style="font-size: 0.7rem; min-width: 60px;">
{% if node.relation_type == 'Afledt af' %}<i class="bi bi-arrow-return-right me-1"></i>Afledt
{% elif node.relation_type == 'Årsag til' %}<i class="bi bi-arrow-down-right me-1"></i>Årsag
{% elif node.relation_type and 'Relateret' in node.relation_type %}<i class="bi bi-arrow-left-right me-1"></i>Rel.
{% else %}{{ node.relation_type }}
{% endif %}
</small>
<div class="d-flex align-items-center flex-grow-1 {{ 'fw-bold bg-light-subtle border border-primary-subtle' if node.is_current else '' }} rounded px-2 py-1" style="min-height: 32px;">
<a href="/sag/{{ node.case.id }}" class="text-decoration-none text-truncate {{ 'text-dark' if node.is_current else 'text-muted' }}" style="max-width: 250px;">
<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>
</div>
{% 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>
{% if node.children %}
{{ render_tree(node.children) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% if relation_tree %}
<div class="relation-tree-container">
<ul class="list-group list-group-flush mb-0">
{% for root in relation_tree %}
<li class="list-group-item border-0 p-0 bg-transparent">
<div class="d-flex align-items-center py-1">
<div class="d-flex align-items-center flex-grow-1 {{ 'fw-bold bg-light-subtle border border-primary-subtle' if root.is_current else '' }} rounded px-2 py-1" style="min-height: 32px;">
<a href="/sag/{{ root.case.id }}" class="text-decoration-none {{ 'text-dark' if root.is_current else 'text-muted' }}">
<span class="badge bg-secondary me-1" style="font-size: 0.7rem;">#{{ root.case.id }}</span>
{{ root.case.titel }}
</a>
<span class="status-dot status-{{ root.case.status }} ms-2" title="{{ root.case.status }}"></span>
</div>
</div>
{% if root.children %}
{{ render_tree(root.children) }}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="text-muted text-center pt-3">Ingen relaterede sager</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100 d-flex flex-column">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">🔧 SAG Kompatibilitet</h6>
<button class="btn btn-sm btn-primary" onclick="showCompatibilityModal()">
Vis
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto text-center d-flex align-items-center justify-content-center" style="max-height: 300px;">
<div>
<i class="bi bi-diagram-3 text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">Klik "Vis" for at se alle</p>
<p class="text-muted small">SAG kompatible moduler</p>
</div>
</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">
<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">
<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>
<!-- 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>
<div class="modal fade" id="compatibilityModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🔧 SAG Kompatible Moduler</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-muted mb-3">📦 Aktive Moduler</h6>
<div class="list-group">
<div class="list-group-item">
<div class="d-flex align-items-center">
<i class="bi bi-check-circle-fill text-success me-2"></i>
<strong>Sager (SAG)</strong>
</div>
<small class="text-muted">Aktiv modul</small>
</div>
<div class="list-group-item">
<div class="d-flex align-items-center">
<i class="bi bi-check-circle-fill text-success me-2"></i>
<strong>Hardware</strong>
</div>
<small class="text-muted">Kan linkes til sager</small>
</div>
<div class="list-group-item">
<div class="d-flex align-items-center">
<i class="bi bi-check-circle-fill text-success me-2"></i>
<strong>Lokationer</strong>
</div>
<small class="text-muted">Kan linkes til sager</small>
</div>
<div class="list-group-item">
<div class="d-flex align-items-center">
<i class="bi bi-check-circle-fill text-success me-2"></i>
<strong>Tags</strong>
</div>
<small class="text-muted">Global tag system</small>
</div>
</div>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-3">🔄 Integration</h6>
<div class="list-group">
<div class="list-group-item">
<div class="d-flex align-items-center">
<i class="bi bi-people me-2 text-primary"></i>
<strong>CRM Integration</strong>
</div>
<small class="text-muted">Kunder & Kontakter</small>
</div>
<div class="list-group-item">
<div class="d-flex align-items-center">
<i class="bi bi-link-45deg me-2 text-primary"></i>
<strong>Relationer</strong>
</div>
<small class="text-muted">Link mellem sager</small>
</div>
<div class="list-group-item">
<div class="d-flex align-items-center">
<i class="bi bi-diagram-3 me-2 text-primary"></i>
<strong>Workflows</strong>
</div>
<small class="text-muted">Tag-baseret automation</small>
</div>
</div>
</div>
</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, compatibilityModal, createRelatedCaseModalInstance;
// 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'));
compatibilityModal = new bootstrap.Modal(document.getElementById('compatibilityModal'));
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');
}
// 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 showCompatibilityModal() {
compatibilityModal.show();
}
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 = hardware.map(h => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold text-primary">
<a href="/hardware/${h.id}" class="text-decoration-none">
${h.brand} ${h.model}
</a>
</div>
<small class="text-muted">SN: ${h.serial_number || 'N/A'}</small>
</div>
<button class="btn btn-sm btn-outline-danger border-0" onclick="unlinkHardware(${h.id})" title="Fjern link">
<i class="bi bi-x-lg"></i>
</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 = locations.map(l => `
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold">
<i class="bi bi-geo-alt me-1 text-secondary"></i>
${l.name}
</div>
<small class="text-muted">${l.location_type || ''}</small>
</div>
<button class="btn btn-sm btn-outline-danger border-0" onclick="unlinkLocation(${l.id})" title="Fjern link">
<i class="bi bi-x-lg"></i>
</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> <!-- End Details Tab -->
<!-- Solution Tab -->
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0">
<!-- 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">
<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>
<!-- Time Tab -->
<div class="tab-pane fade" id="time" role="tabpanel" tabindex="0">
<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-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">
<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>Bruger</th>
<th>Timer</th>
<th class="text-end pe-4">Status</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr>
<td class="ps-4">{{ entry.worked_date }}</td>
<td>
<div>{{ entry.description or '-' }}</div>
{% if entry.solution_id %}
<div class="small text-muted"><i class="bi bi-link me-1"></i>Koblet til løsning</div>
{% endif %}
</td>
<td>{{ entry.user_name }}</td>
<td class="fw-bold">{{ entry.original_hours }}</td>
<td class="text-end pe-4">
<span class="badge bg-light text-dark border">{{ entry.status }}</span>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-4 text-muted">Ingen tid registreret</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div> <!-- End Tab Content -->
<!-- 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>
<!-- 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">
<!-- Date selection -->
<div class="col-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="time_date" value="{{ now_date }}" required>
</div>
<!-- Split Hours/Minutes -->
<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" placeholder="mm" step="1">
</div>
<div class="form-text text-end" id="timeTotalCalc" style="font-size: 0.8rem;">Total: 0.00 timer</div>
</div>
<!-- Work Type -->
<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>
<!-- Billing Method / Prepaid -->
<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 ({{ prepaid_cards|length }} fundet)">
{% for card in prepaid_cards %}
<option value="card_{{ card.id }}">
💳 #{{ card.card_number }} ({{ card.remaining_hours|round(2) }}t)
</option>
{% endfor %}
</optgroup>
{% else %}
<optgroup label="Klippekort">
<option disabled>(Ingen kort fundet - KundeID: {{ case.customer_id }})</option>
</optgroup>
{% endif %}
<optgroup label="Standard">
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
<option value="unknown">❓ Ved ikke</option>
</optgroup>
</select>
</div>
<!-- Description -->
<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>
<!-- Internal Toggle -->
<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;
// Handle prepaid card selection formatting (card_123)
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
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;
}
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); }
}
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); }
}
// ==========================================
// 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="${f.download_url}" target="_blank" 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>
<button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${f.id})">
<i class="bi bi-x-lg"></i>
</button>
</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 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 %}