- 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.
3182 lines
152 KiB
HTML
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, '"').replace(/'/g, ''');
|
|
const safeCustomer = customerName.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
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 %}
|