bmc_hub/app/modules/sag/templates/detail.html
Christian d5dd958bf9 Refactor Sager module templates and functionality
- Updated index.html to extend base template and improve structure.
- Added new styles and search/filter functionality in the Sager list view.
- Created a backup of the old index.html as index_old.html.
- Updated navigation links in base.html for consistency.
- Included new dashboard API router in main.py.
- Added test scripts for customer and sag queries to validate database interactions.
2026-02-01 11:58:44 +01:00

1371 lines
60 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;">
<!-- Back Link -->
<a href="/sag" class="back-link">
<i class="bi bi-chevron-left"></i> Tilbage til sager
</a>
<!-- 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>
<!-- ROW 1: Main Info + Tags + Hardware (40% height) -->
<div class="row mb-3" style="height: 40vh;">
<!-- Main Case Info (50% width) -->
<div class="col-md-6 h-100">
<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" 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">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>
<!-- Tags + Hardware (50% width) -->
<div class="col-md-6 h-100">
<div class="row h-50 mb-2">
<div class="col-12">
<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);">📌 Tags</h6>
<button class="btn btn-sm btn-outline-primary" onclick="window.showTagPicker('case', {{ case.id }}, () => window.renderEntityTags('case', {{ case.id }}, 'case-tags'))">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto">
<div id="case-tags" class="d-flex flex-wrap">
<div class="text-center w-100 py-2">
<span class="spinner-border spinner-border-sm text-muted"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row h-50">
<div class="col-12">
<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);">💻 Hardware</h6>
<button class="btn btn-sm btn-outline-primary" onclick="promptLinkHardware()">
<i class="bi bi-link-45deg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto p-0">
<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>
</div>
</div>
<!-- ROW 2: Locations + Contacts + Customers (35% height) -->
<div class="row mb-3" style="height: 35vh;">
<div class="col-md-4 h-100">
<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="promptLinkLocation()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto p-0">
<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 h-100">
<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="showContactSearch()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" id="contacts-container">
{% 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 h-100">
<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="showCustomerSearch()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" id="customers-container">
{% 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 (25% height) -->
<div class="row" style="height: 25vh;">
<div class="col-md-8 h-100">
<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>
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto">
{% if relationer %}
{% for rel in relationer %}
<div class="relation-card">
<span class="relation-type">{{ rel.relationstype }}</span>
{% if rel.kilde_sag_id == case.id %}
<a href="/sag/{{ rel.målsag_id }}" class="relation-link">{{ rel.mål_titel }}</a>
{% else %}
<a href="/sag/{{ rel.kilde_sag_id }}" class="relation-link">{{ rel.kilde_titel }}</a>
{% endif %}
<button onclick="deleteRelation({{ rel.id }})" class="btn btn-sm btn-delete float-end"></button>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Ingen relaterede sager</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4 h-100">
<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">
<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>
<!-- 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;
// 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'));
// 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();
});
// 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 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>
<!-- 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>
</div>
{% endblock %}