- Implement test script for new SAG module endpoints BE-003 (Tag State Management) and BE-004 (Bulk Operations). - Create test cases for creating, updating, and bulk operations on cases and tags. - Add a test for module deactivation to ensure data integrity is maintained. - Include setup and teardown for tests to clear database state before and after each test.
906 lines
37 KiB
HTML
906 lines
37 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" style="margin-top: 2rem; margin-bottom: 2rem;">
|
|
<!-- Back Link -->
|
|
<a href="/cases" class="back-link">
|
|
<i class="bi bi-chevron-left"></i> Tilbage til sager
|
|
</a>
|
|
|
|
<!-- Main Info Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h1 style="margin: 0; color: var(--accent);">{{ case.titel }}</h1>
|
|
</div>
|
|
<div class="card-body">
|
|
<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>
|
|
|
|
<!-- Tags Card (Global) -->
|
|
<div class="card mb-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0" style="color: var(--accent);">📌 Tags</h5>
|
|
<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> Tilføj
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<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>
|
|
|
|
<!-- Related Hardware -->
|
|
<div class="card mb-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0" style="color: var(--accent);">💻 Hardware</h5>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="promptLinkHardware()">
|
|
<i class="bi bi-link-45deg"></i> Link ID
|
|
</button>
|
|
</div>
|
|
<div class="card-body 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>
|
|
|
|
<!-- Related Locations -->
|
|
<div class="card mb-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0" style="color: var(--accent);">📍 Lokationer</h5>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="promptLinkLocation()">
|
|
<i class="bi bi-plus-lg"></i> Tilføj ID
|
|
</button>
|
|
</div>
|
|
<div class="card-body 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>
|
|
|
|
|
|
<!-- Relations Section -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 style="margin: 0;">🔗 Relaterede sager</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
{% if relations %}
|
|
{% for rel in relations %}
|
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #eee;">
|
|
<div>
|
|
<div class="relation-type" style="display: inline-block; background: var(--accent-light); color: var(--accent-color); padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.85rem; margin-right: 0.5rem;">{{ rel.relationstype }}</div>
|
|
{% if rel.kilde_sag_id == case.id %}
|
|
→ <a href="/cases/{{ rel.målsag_id }}" class="relation-link" style="color: var(--accent-color); text-decoration: none;">{{ rel.mål_titel }}</a>
|
|
{% else %}
|
|
← <a href="/cases/{{ rel.kilde_sag_id }}" class="relation-link" style="color: var(--accent-color); text-decoration: none;">{{ rel.kilde_titel }}</a>
|
|
{% endif %}
|
|
</div>
|
|
<button onclick="deleteRelation({{ rel.id }})" class="btn btn-sm" style="background-color: #e74c3c; color: white; padding: 0.25rem 0.5rem;">✕</button>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<p style="color: #999;">Ingen relaterede sager.</p>
|
|
{% endif %}
|
|
</div>
|
|
<div class="card-footer">
|
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
|
<div style="flex: 1; min-width: 200px; position: relative;">
|
|
<input type="text" id="relationCaseSearch" placeholder="Søg efter relateret sag..." class="form-control" style="font-size: 0.9rem;">
|
|
<div id="relationSearchResults" style="position: absolute; top: 100%; left: 0; right: 0; background: var(--bg-body); border: 1px solid #ddd; max-height: 200px; overflow-y: auto; display: none; z-index: 10;"></div>
|
|
</div>
|
|
<select id="relationTypeSelect" class="form-control" style="flex: 1; min-width: 150px;">
|
|
<option value="">Vælg relationstype</option>
|
|
<option value="relateret">Relateret</option>
|
|
<option value="afhænger af">Afhænger af</option>
|
|
<option value="blokkerer">Blokkerer</option>
|
|
<option value="duplikat">Duplikat</option>
|
|
</select>
|
|
<button onclick="addRelation()" class="btn" style="background-color: var(--accent-color); color: white; padding: 0.5rem 1rem;">+</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contacts Section -->
|
|
{% if contacts %}
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 style="margin: 0;">👥 Tilknyttede kontakter</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
{% for contact in contacts %}
|
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #eee;">
|
|
<div>
|
|
<strong>{{ contact.contact_name }}</strong>
|
|
<div style="font-size: 0.9rem; color: #666;">{{ contact.role }}</div>
|
|
{% if contact.contact_email %}
|
|
<div style="font-size: 0.85rem; color: #999;">{{ contact.contact_email }}</div>
|
|
{% endif %}
|
|
</div>
|
|
<button onclick="removeContact({{ case.id }}, {{ contact.contact_id }})" class="btn btn-sm" style="background-color: #e74c3c; color: white; padding: 0.25rem 0.5rem;">✕</button>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div class="card-footer">
|
|
<div style="position: relative;">
|
|
<input type="text" id="contactSearch" placeholder="Søg efter kontakt..." class="form-control" style="font-size: 0.9rem;">
|
|
<div id="contactSearchResults" style="position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; max-height: 200px; overflow-y: auto; display: none; z-index: 10;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 style="margin: 0;">👥 Tilknyttede kontakter</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p style="color: #999;">Ingen kontakter tilknyttet denne sag.</p>
|
|
</div>
|
|
<div class="card-footer">
|
|
<div style="position: relative;">
|
|
<input type="text" id="contactSearch" placeholder="Søg efter kontakt..." class="form-control" style="font-size: 0.9rem;">
|
|
<div id="contactSearchResults" style="position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; max-height: 200px; overflow-y: auto; display: none; z-index: 10;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Customers Section -->
|
|
{% if customers %}
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 style="margin: 0;">🏢 Tilknyttede kunder</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
{% for customer in customers %}
|
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #eee;">
|
|
<div>
|
|
<strong><a href="/customers/{{ customer.customer_id }}" style="color: var(--accent-color); text-decoration: none;">{{ customer.customer_name }}</a></strong>
|
|
<div style="font-size: 0.9rem; color: #666;">{{ customer.role }}</div>
|
|
{% if customer.customer_email %}
|
|
<div style="font-size: 0.85rem; color: #999;">{{ customer.customer_email }}</div>
|
|
{% endif %}
|
|
</div>
|
|
<button onclick="removeCustomer({{ case.id }}, {{ customer.customer_id }})" class="btn btn-sm" style="background-color: #e74c3c; color: white; padding: 0.25rem 0.5rem;">✕</button>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div class="card-footer">
|
|
<div style="position: relative;">
|
|
<input type="text" id="customerSearch" placeholder="Søg efter kunde..." class="form-control" style="font-size: 0.9rem;">
|
|
<div id="customerSearchResults" style="position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; max-height: 200px; overflow-y: auto; display: none; z-index: 10;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 style="margin: 0;">🏢 Tilknyttede kunder</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p style="color: #999;">Ingen kunder tilknyttet denne sag.</p>
|
|
</div>
|
|
<div class="card-footer">
|
|
<div style="position: relative;">
|
|
<input type="text" id="customerSearch" placeholder="Søg efter kunde..." class="form-control" style="font-size: 0.9rem;">
|
|
<div id="customerSearchResults" style="position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; max-height: 200px; overflow-y: auto; display: none; z-index: 10;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Action Buttons -->
|
|
<div style="margin-top: 2rem; display: flex; gap: 1rem; flex-wrap: wrap;">
|
|
<a href="/cases/{{ case.id }}/edit" class="btn" style="background-color: var(--accent-color); color: white;">✏️ Rediger</a>
|
|
<button class="btn" style="background-color: #e74c3c; color: white;" onclick="if(confirm('Slet denne sag?')) { fetch('/api/v1/cases/{{ case.id }}', {method: 'DELETE'}).then(() => window.location='/cases'); }">🗑️ Slet</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
const caseId = {{ case.id }};
|
|
let contactSearchTimeout;
|
|
let customerSearchTimeout;
|
|
|
|
// Initialize search functionality when DOM is ready
|
|
function initializeSearch() {
|
|
const contactSearchInput = document.getElementById('contactSearch');
|
|
const customerSearchInput = document.getElementById('customerSearch');
|
|
|
|
if (contactSearchInput) {
|
|
contactSearchInput.addEventListener('input', function(e) {
|
|
clearTimeout(contactSearchTimeout);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 2) {
|
|
document.getElementById('contactSearchResults').style.display = 'none';
|
|
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 style="padding: 10px; color: #999;">Ingen kontakter fundet</div>';
|
|
} else {
|
|
resultsDiv.innerHTML = contacts.map(c => `
|
|
<div style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer;"
|
|
onmouseover="this.style.background='#f5f5f5'"
|
|
onmouseout="this.style.background='white'"
|
|
onclick="addContact(${caseId}, ${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
|
|
<strong>${c.first_name} ${c.last_name}</strong>
|
|
<div style="font-size: 0.85rem; color: #999;">${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
resultsDiv.style.display = 'block';
|
|
} catch (err) {
|
|
console.error('Error searching contacts:', err);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
if (customerSearchInput) {
|
|
customerSearchInput.addEventListener('input', function(e) {
|
|
clearTimeout(customerSearchTimeout);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 2) {
|
|
document.getElementById('customerSearchResults').style.display = 'none';
|
|
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 style="padding: 10px; color: #999;">Ingen kunder fundet</div>';
|
|
} else {
|
|
resultsDiv.innerHTML = customers.map(c => `
|
|
<div style="padding: 10px; border-bottom: 1px solid #eee; cursor: pointer;"
|
|
onmouseover="this.style.background='#f5f5f5'"
|
|
onmouseout="this.style.background='white'"
|
|
onclick="addCustomer(${caseId}, ${c.id}, '${c.name.replace(/'/g, "\\'")}')">
|
|
<strong>${c.name}</strong>
|
|
<div style="font-size: 0.85rem; color: #999;">${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
resultsDiv.style.display = 'block';
|
|
} catch (err) {
|
|
console.error('Error searching customers:', err);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initializeSearch);
|
|
} else {
|
|
initializeSearch();
|
|
}
|
|
|
|
async function addContact(caseId, contactId, contactName) {
|
|
try {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/contacts`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({contact_id: contactId, role: 'Kontakt'})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const contactSearch = document.getElementById('contactSearch');
|
|
if (contactSearch) contactSearch.value = '';
|
|
const contactResults = document.getElementById('contactSearchResults');
|
|
if (contactResults) contactResults.style.display = 'none';
|
|
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 addCustomer(caseId, customerId, customerName) {
|
|
try {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/customers`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({customer_id: customerId, role: 'Kunde'})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const customerSearch = document.getElementById('customerSearch');
|
|
if (customerSearch) customerSearch.value = '';
|
|
const customerResults = document.getElementById('customerSearchResults');
|
|
if (customerResults) customerResults.style.display = 'none';
|
|
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 removeContact(caseId, contactId) {
|
|
if (confirm('Fjern denne kontakt fra sagen?')) {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/contacts/${contactId}`, {method: 'DELETE'});
|
|
if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
alert('Fejl ved fjernelse af kontakt');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function removeCustomer(caseId, customerId) {
|
|
if (confirm('Fjern denne kunde fra sagen?')) {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/customers/${customerId}`, {method: 'DELETE'});
|
|
if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
alert('Fejl ved fjernelse af kunde');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tag Management Functions
|
|
async function addTag() {
|
|
const tagInput = document.getElementById('newTagInput');
|
|
const tagName = tagInput.value.trim();
|
|
|
|
if (!tagName) {
|
|
alert('Indtast et tag navn');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/tags`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({tag_navn: tagName})
|
|
});
|
|
|
|
if (response.ok) {
|
|
tagInput.value = '';
|
|
window.location.reload();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`Fejl: ${error.detail}`);
|
|
}
|
|
} catch (err) {
|
|
alert('Fejl ved tilføjelse af tag: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function deleteTag(tagId) {
|
|
if (confirm('Fjern dette tag?')) {
|
|
try {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/tags/${tagId}`, {method: 'DELETE'});
|
|
if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
alert('Fejl ved fjernelse af tag');
|
|
}
|
|
} catch (err) {
|
|
alert('Fejl: ' + err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function toggleTagState(tagId, currentState) {
|
|
const newState = currentState === 'open' ? 'closed' : 'open';
|
|
try {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/tags/${tagId}/state`, {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({state: newState})
|
|
});
|
|
if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
alert('Fejl ved opdatering af tag status');
|
|
}
|
|
} catch (err) {
|
|
alert('Fejl: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// Relation Management Functions
|
|
let selectedRelationCaseId = null;
|
|
|
|
function initializeRelationSearch() {
|
|
const relationSearchInput = document.getElementById('relationCaseSearch');
|
|
if (!relationSearchInput) return;
|
|
|
|
relationSearchInput.addEventListener('input', function(e) {
|
|
clearTimeout(relationSearchTimeout);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 2) {
|
|
document.getElementById('relationSearchResults').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
relationSearchTimeout = setTimeout(async () => {
|
|
try {
|
|
const response = await fetch(`/api/v1/search/cases?q=${encodeURIComponent(query)}`);
|
|
const cases = await response.json();
|
|
|
|
const resultsDiv = document.getElementById('relationSearchResults');
|
|
if (cases.length === 0) {
|
|
resultsDiv.innerHTML = '<div style="padding: 10px; color: #999;">Ingen sager fundet</div>';
|
|
} else {
|
|
resultsDiv.innerHTML = cases
|
|
.filter(c => c.id !== caseId) // Exclude current case
|
|
.map(c => `
|
|
<div onclick="selectRelationCase(${c.id}, '${c.titel.replace(/'/g, "\\'")}');" style="padding: 10px; cursor: pointer; border-bottom: 1px solid #eee; transition: background 0.2s;" onmouseover="this.style.background='var(--accent-light)'" onmouseout="this.style.background='transparent'">
|
|
<div style="font-weight: 600; color: var(--accent-color);">${c.titel}</div>
|
|
<div style="font-size: 0.85rem; color: var(--text-secondary);">Status: ${c.status}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
resultsDiv.style.display = 'block';
|
|
} catch (err) {
|
|
console.error('Error searching cases:', err);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
function selectRelationCase(caseIdValue, caseTitel) {
|
|
selectedRelationCaseId = caseIdValue;
|
|
document.getElementById('relationCaseSearch').value = caseTitel;
|
|
document.getElementById('relationSearchResults').style.display = 'none';
|
|
}
|
|
|
|
async function addRelation() {
|
|
const relationType = document.getElementById('relationTypeSelect').value;
|
|
|
|
if (!selectedRelationCaseId) {
|
|
alert('Vælg en sag');
|
|
return;
|
|
}
|
|
|
|
if (!relationType) {
|
|
alert('Vælg en relationstype');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/cases/${caseId}/relations`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
målsag_id: selectedRelationCaseId,
|
|
relationstype: relationType
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
selectedRelationCaseId = null;
|
|
document.getElementById('relationCaseSearch').value = '';
|
|
document.getElementById('relationTypeSelect').value = '';
|
|
window.location.reload();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`Fejl: ${error.detail}`);
|
|
}
|
|
} catch (err) {
|
|
alert('Fejl ved tilføjelse af relation: ' + err.message);
|
|
}
|
|
}
|
|
|
|
|
|
// Initialize Global Elements
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Render Global Tags
|
|
if (window.renderEntityTags) {
|
|
window.renderEntityTags('case', {{ case.id }}, 'case-tags');
|
|
}
|
|
|
|
// Load Hardware & Locations
|
|
loadCaseHardware();
|
|
loadCaseLocations();
|
|
});
|
|
|
|
// ============ Hardware Handling ============
|
|
async function loadCaseHardware() {
|
|
try {
|
|
const res = await fetch(`/api/v1/cases/{{ 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/cases/{{ 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/cases/{{ 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/cases/{{ 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/cases/{{ 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/cases/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
|
|
loadCaseLocations();
|
|
} catch (e) {
|
|
alert("Fejl ved sletning");
|
|
}
|
|
}
|
|
|
|
|
|
// Initialize relation search when DOM is ready
|
|
let relationSearchTimeout;
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initializeRelationSearch);
|
|
} else {
|
|
initializeRelationSearch();
|
|
}
|
|
</script>
|
|
</div>
|
|
{% endblock %}
|