bmc_hub/app/modules/hardware/templates/detail.html

1284 lines
65 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}{{ hardware.brand }} {{ hardware.model }} - Hardware - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.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);
word-break: break-all;
}
.action-card {
background: white;
padding: 1rem;
border-radius: 8px;
text-align: center;
border: 1px dashed rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.2s;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
}
.action-card:hover {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
text-decoration: none;
}
.action-card i {
font-size: 1.5rem;
}
/* Timeline Styling */
.timeline {
position: relative;
padding-left: 1.5rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.4rem;
top: 0;
bottom: 0;
width: 2px;
background: #e9ecef;
}
.timeline-item {
position: relative;
padding-bottom: 1.5rem;
}
.timeline-marker {
position: absolute;
left: -1.09rem;
top: 0.3rem;
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
background: white;
border: 2px solid var(--accent);
}
.timeline-item.active .timeline-marker {
background: #28a745;
border-color: #28a745;
}
/* Quick Info Bar */
.quick-info-label {
color: var(--accent);
font-weight: 700;
margin-right: 0.4rem;
}
.quick-info-item {
display: flex;
align-items: center;
border-right: 1px solid rgba(0,0,0,0.1);
padding-right: 0.75rem;
margin-right: 0.75rem;
}
.quick-info-item:last-child {
border-right: none;
margin-right: 0;
padding-right: 0;
}
</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="/hardware" class="back-link text-decoration-none">
<i class="bi bi-chevron-left"></i> Tilbage til hardware
</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="hardware-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('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-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" style="font-size: 0.85rem;">
<!-- ID -->
<div class="quick-info-item">
<span class="quick-info-label">ID:</span>
<span>{{ hardware.id }}</span>
</div>
<!-- Brand / Model -->
<div class="quick-info-item">
<span class="quick-info-label">Hardware:</span>
<span class="fw-bold">{{ hardware.brand or 'Unknown' }} {{ hardware.model or '' }}</span>
</div>
<!-- Serial -->
{% if hardware.serial_number %}
<div class="quick-info-item">
<span class="quick-info-label">S/N:</span>
<span class="font-monospace text-muted">{{ hardware.serial_number }}</span>
</div>
{% endif %}
<!-- Customer (Current Owner) -->
{% set current_owner = ownership[0] if ownership else None %}
{% set owner_contact_ns = namespace(contact=None) %}
{% if contacts %}
{% for c in contacts %}
{% if c.role == 'primary' and owner_contact_ns.contact is none %}
{% set owner_contact_ns.contact = c %}
{% endif %}
{% endfor %}
{% endif %}
{% if current_owner and not current_owner.end_date %}
<div class="quick-info-item">
<span class="quick-info-label">Ejer:</span>
<span>{{ current_owner.customer_name or current_owner.owner_type|title }}</span>
</div>
{% endif %}
<!-- Location (Current) -->
{% set current_loc = locations[0] if locations else None %}
{% if current_loc and not current_loc.end_date %}
<div class="quick-info-item">
<span class="quick-info-label">Lokation:</span>
<span>{{ current_loc.location_name }}</span>
</div>
{% endif %}
<!-- Status -->
<div class="quick-info-item">
<span class="quick-info-label">Status:</span>
<span class="badge {% if hardware.status == 'active' %}bg-success{% elif hardware.status == 'retired' %}bg-secondary{% elif hardware.status == 'in_repair' %}bg-primary{% else %}bg-warning{% endif %}" style="font-size: 0.7rem;">
{{ hardware.status|replace('_', ' ')|title }}
</span>
</div>
<!-- Link to Edit -->
<div class="ms-auto d-flex gap-2">
<button onclick="syncEsetData()" class="btn btn-sm btn-outline-secondary" title="Opdater fra ESET">
<i class="bi bi-arrow-repeat"></i>
</button>
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary" title="Rediger">
<i class="bi bi-pencil"></i>
</a>
<button onclick="deleteHardware()" class="btn btn-sm btn-outline-danger" title="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4 px-2" id="hwTabs" 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>Hardware Detaljer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="eset-tab" data-bs-toggle="tab" data-bs-target="#eset-specs" type="button" role="tab">
<i class="bi bi-cpu me-2"></i>Specifikationer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
<i class="bi bi-clock-history me-2"></i>Historik
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">
<i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }})
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
<i class="bi bi-sticky me-2"></i>Noter
</button>
</li>
</ul>
<div class="tab-content" id="hwTabsContent">
<!-- Tab: Details -->
<div class="tab-pane fade show active" id="details" role="tabpanel">
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- Basic Info Card -->
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-info-circle me-2"></i>Stamdata</h6>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">
{% if hardware.asset_type == 'pc' %}🖥️ PC
{% elif hardware.asset_type == 'laptop' %}💻 Laptop
{% elif hardware.asset_type == 'printer' %}🖨️ Printer
{% elif hardware.asset_type == 'skærm' %}🖥️ Skærm
{% elif hardware.asset_type == 'telefon' %}📱 Telefon
{% elif hardware.asset_type == 'server' %}🗄️ Server
{% elif hardware.asset_type == 'netværk' %}🌐 Netværk
{% else %}📦 {{ hardware.asset_type|title }}
{% endif %}
</span>
</div>
<div class="info-row">
<span class="info-label">Mærke/Model</span>
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
</div>
{% if hardware.eset_uuid %}
<div class="info-row">
<span class="info-label">ESET UUID</span>
<span class="info-value" style="word-break: break-all;">{{ hardware.eset_uuid }}</span>
</div>
{% endif %}
{% if hardware.eset_group %}
<div class="info-row">
<span class="info-label">ESET Gruppe</span>
<span class="info-value" style="word-break: break-all;">{{ hardware.eset_group }}</span>
</div>
{% endif %}
{% if hardware.internal_asset_id %}
<div class="info-row">
<span class="info-label">Internt Asset ID</span>
<span class="info-value">{{ hardware.internal_asset_id }}</span>
</div>
{% endif %}
{% if hardware.customer_asset_id %}
<div class="info-row">
<span class="info-label">Kunde Asset ID</span>
<span class="info-value">{{ hardware.customer_asset_id }}</span>
</div>
{% endif %}
{% if hardware.warranty_until %}
<div class="info-row">
<span class="info-label">Garanti til</span>
<span class="info-value">{{ hardware.warranty_until }}</span>
</div>
{% endif %}
{% if hardware.end_of_life %}
<div class="info-row">
<span class="info-label">End of Life</span>
<span class="info-value">{{ hardware.end_of_life }}</span>
</div>
{% endif %}
</div>
</div>
<!-- AnyDesk Card -->
{% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %}
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-danger mb-0"><i class="bi bi-display me-2"></i>Remote Access</h6>
<a class="btn btn-sm btn-link p-0" href="/hardware/{{ hardware.id }}/edit#anydesk" title="Rediger AnyDesk">
Ændre
</a>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">AnyDesk ID</span>
<span class="info-value fw-bold font-monospace">
{% if anydesk_url %}
<a href="{{ anydesk_url }}" target="_blank" rel="noreferrer noopener">{{ hardware.anydesk_id }}</a>
{% else %}
-
{% endif %}
</span>
</div>
<div class="info-row">
<span class="info-label">Handling</span>
<span class="info-value">
{% if anydesk_url %}
<a href="{{ anydesk_url }}" target="_blank" rel="noreferrer noopener" class="btn btn-sm btn-outline-danger">
<i class="bi bi-lightning-charge me-1"></i>Connect AnyDesk
</a>
{% else %}
<span class="text-muted small">Ingen link</span>
{% endif %}
</span>
</div>
</div>
</div>
<!-- Location & Owner Grid -->
<div class="row">
<div class="col-md-6">
<div class="card h-100 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-primary mb-0"><i class="bi bi-geo-alt me-2"></i>Lokation</h6>
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#locationModal">Ændre</button>
</div>
<div class="card-body">
{% if current_loc and not current_loc.end_date %}
<div class="text-center py-3">
<div class="fs-4 mb-2"><i class="bi bi-building"></i></div>
<h5 class="fw-bold">{{ current_loc.location_name }}</h5>
<p class="text-muted small mb-0">Siden: {{ current_loc.start_date }}</p>
{% if current_loc.notes %}
<div class="mt-2 text-muted fst-italic small">"{{ current_loc.notes }}"</div>
{% endif %}
</div>
{% else %}
<div class="text-center py-4 text-muted">
<p class="mb-2">Ingen aktiv lokation</p>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#locationModal">Tildel nu</button>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-success mb-0"><i class="bi bi-person me-2"></i>Ejer</h6>
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#ownerModal">Ændre</button>
</div>
<div class="card-body">
{% if current_owner and not current_owner.end_date %}
<div class="text-center py-3">
<div class="fs-4 mb-2 text-success"><i class="bi bi-person-badge"></i></div>
<h5 class="fw-bold">{{ current_owner.customer_name or current_owner.owner_type|title }}</h5>
{% if owner_contact_ns.contact %}
<p class="mb-1">
<span class="badge bg-light text-dark border">{{ owner_contact_ns.contact.first_name }} {{ owner_contact_ns.contact.last_name }}</span>
</p>
{% endif %}
<p class="text-muted small mb-0">Siden: {{ current_owner.start_date }}</p>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<p class="mb-0">Ingen aktiv ejer</p>
<button class="btn btn-sm btn-outline-success mt-2" data-bs-toggle="modal" data-bs-target="#ownerModal">Sæt ejer</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Quick Actions & Related -->
<div class="col-lg-4">
<!-- Quick Actions -->
<div class="row g-2 mb-4">
<div class="col-6">
<a href="#" class="action-card text-decoration-none" onclick="alert('Funktion: Opret Sag til dette hardware (kommer snart)')">
<i class="bi bi-ticket-perforated text-primary"></i>
<span class="small fw-bold">Opret Sag</span>
</a>
</div>
<div class="col-6">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-geo-alt text-primary"></i>
<span class="small fw-bold">Skift Lokation</span>
</div>
</div>
<div class="col-6">
<a href="/app/locations" class="action-card text-decoration-none">
<i class="bi bi-building-add text-secondary"></i>
<span class="small fw-bold">Ny Lokation</span>
</a>
</div>
<div class="col-6">
<div class="action-card" onclick="alert('Funktion: Upload bilag (kommer snart)')">
<i class="bi bi-paperclip text-secondary"></i>
<span class="small fw-bold">Tilføj Bilag</span>
</div>
</div>
</div>
<!-- Contacts -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-secondary mb-0 fw-bold small"><i class="bi bi-person-lines-fill me-2"></i>Kontaktpersoner</h6>
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-bs-toggle="modal" data-bs-target="#addContactModal">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="list-group list-group-flush">
{% if contacts %}
{% for contact in contacts %}
<div class="list-group-item border-0 px-3 py-2">
<div class="d-flex w-100 justify-content-between align-items-center">
<div class="text-truncate" style="max-width: 85%;">
<div class="fw-bold text-dark small">
{{ contact.first_name }} {{ contact.last_name }}
{% if contact.role == 'primary' %}<span class="badge bg-info text-dark ms-1" style="font-size: 0.6rem;">Primær</span>{% endif %}
{% if contact.source == 'eset' %}<span class="badge bg-light text-muted border ms-1" style="font-size: 0.6rem;">ESET</span>{% endif %}
</div>
<div class="text-muted small" style="font-size: 0.75rem;">
{% if contact.email %}<a href="mailto:{{ contact.email }}" class="text-muted text-decoration-none"><i class="bi bi-envelope me-1"></i></a>{% endif %}
{% if contact.phone %}<a href="tel:{{ contact.phone }}" class="text-muted text-decoration-none"><i class="bi bi-phone me-1"></i>{{ contact.phone }}</a>{% endif %}
</div>
</div>
<form action="/hardware/{{ hardware.id }}/contacts/{{ contact.contact_id }}/delete" method="POST" onsubmit="return confirm('Er du sikker på at du vil fjerne denne kontakt?');">
<button type="submit" class="btn btn-sm btn-link text-danger p-0" title="Fjern kontakt">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-3 text-muted small">
Ingen kontakter tilknyttet
</div>
{% endif %}
</div>
</div>
<!-- Linked Cases -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-secondary mb-0"><i class="bi bi-briefcase me-2"></i>Seneste Sager</h6>
</div>
<div class="list-group list-group-flush">
{% if cases and cases|length > 0 %}
{% for case in cases[:5] %}
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action border-0 px-3 py-2">
<div class="d-flex w-100 justify-content-between align-items-center">
<div class="text-truncate" style="max-width: 70%;">
<i class="bi bi-ticket me-1 text-muted small"></i>
<span class="small fw-bold text-dark">{{ case.titel }}</span>
</div>
<span class="badge bg-light text-dark border">{{ case.status }}</span>
</div>
<div class="small text-muted ms-3">{{ case.created_at.strftime('%Y-%m-%d') if case.created_at else '' }}</div>
</a>
{% endfor %}
{% if cases|length > 5 %}
<div class="card-footer bg-white text-center p-2">
<a href="#cases-tab" class="small text-decoration-none" onclick="document.getElementById('cases-tab').click()">Se alle {{ cases|length }} sager</a>
</div>
{% endif %}
{% else %}
<div class="text-center py-4 text-muted small">
Ingen sager tilknyttet
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Specs -->
<div class="tab-pane fade" id="eset-specs" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-cpu me-2"></i>Specifikationer</h6>
</div>
<div class="card-body">
{% if eset_specs and (eset_specs.os_name or eset_specs.primary_local_ip or eset_specs.cpu_models or eset_specs.deployed_components) %}
<div class="table-responsive">
<table class="table table-sm align-middle">
<tbody>
<tr>
<th style="width: 200px;">Enhedsnavn</th>
<td>{{ eset_specs.device_name or '-' }}</td>
</tr>
<tr>
<th>OS</th>
<td>{{ eset_specs.os_name or '-' }}{% if eset_specs.os_version %} ({{ eset_specs.os_version }}){% endif %}</td>
</tr>
<tr>
<th>Sidst sync</th>
<td>{{ eset_specs.last_sync_time or '-' }}</td>
</tr>
<tr>
<th>Status</th>
<td>{{ eset_specs.functionality_status or '-' }}</td>
</tr>
<tr>
<th>Device type</th>
<td>{{ eset_specs.device_type or '-' }}</td>
</tr>
<tr>
<th>Local IP</th>
<td>{{ eset_specs.primary_local_ip or '-' }}</td>
</tr>
<tr>
<th>Public IP</th>
<td>{{ eset_specs.public_ip or '-' }}</td>
</tr>
<tr>
<th>Chassis</th>
<td>{{ eset_specs.manufacturer or '-' }} / {{ eset_specs.model or '-' }}</td>
</tr>
<tr>
<th>BIOS Serial</th>
<td>{{ eset_specs.bios_serial or '-' }}</td>
</tr>
<tr>
<th>CPU</th>
<td>{{ eset_specs.cpu_models | join(', ') if eset_specs.cpu_models else '-' }}</td>
</tr>
<tr>
<th>Disk</th>
<td>{{ eset_specs.disk_summaries | join(', ') if eset_specs.disk_summaries else (eset_specs.disk_models | join(', ') if eset_specs.disk_models else '-') }}</td>
</tr>
<tr>
<th>Netkort</th>
<td>{{ eset_specs.adapter_names | join(', ') if eset_specs.adapter_names else '-' }}</td>
</tr>
<tr>
<th>MAC</th>
<td>{{ eset_specs.macs | join(', ') if eset_specs.macs else '-' }}</td>
</tr>
<tr>
<th>Installeret</th>
<td>
{% if eset_specs.installed_software_details %}
<div style="max-height: 240px; overflow: auto; border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.02);">
<table class="table table-sm mb-0 align-middle">
<thead>
<tr>
<th style="width: 70%;">App</th>
<th>Version</th>
</tr>
</thead>
<tbody>
{% for app in eset_specs.installed_software_details %}
<tr>
<td class="text-break">{{ app.name }}</td>
<td>{{ app.version or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif eset_specs.deployed_components %}
<div style="max-height: 240px; overflow: auto; border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.02);">
<ul class="mb-0 ps-3">
{% for app in eset_specs.deployed_components %}
<li>{{ app }}</li>
{% endfor %}
</ul>
</div>
{% else %}
-
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">Ingen ESET specifikationer fundet endnu.</div>
{% endif %}
</div>
</div>
{% if hardware.hardware_specs %}
<div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-shield-check me-2"></i>ESET Data</h6>
</div>
<div class="card-body">
<div class="mb-2 text-muted" style="font-size: 0.85rem;">
Rå data fra ESET (til match/diagnose)
</div>
<pre class="p-3 rounded" style="background: var(--bg-body); border: 1px solid rgba(0,0,0,0.1); max-height: 420px; overflow: auto; font-size: 0.85rem;">{{ hardware.hardware_specs | tojson(indent=2) }}</pre>
</div>
</div>
{% endif %}
</div>
<!-- Tab: History -->
<div class="tab-pane fade" id="history" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary mb-3 ps-3 border-start border-3 border-primary">Lokations Historik</h6>
<div class="timeline">
{% if locations %}
{% for loc in locations %}
<div class="timeline-item {% if not loc.end_date %}active{% endif %}">
<div class="timeline-marker"></div>
<div class="ps-2">
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }}</div>
<div class="text-muted small">
{{ loc.start_date }}
{% if loc.end_date %} - {{ loc.end_date }}{% else %} <span class="badge bg-success py-0">Nuværende</span>{% endif %}
</div>
{% if loc.notes %}<div class="text-muted small fst-italic mt-1">"{{ loc.notes }}"</div>{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted ps-4">Ingen historik.</p>
{% endif %}
</div>
</div>
<div class="col-md-6">
<h6 class="text-success mb-3 ps-3 border-start border-3 border-success">Ejerskabs Historik</h6>
<div class="timeline">
{% if ownership %}
{% for own in ownership %}
<div class="timeline-item {% if not own.end_date %}active{% endif %}">
<div class="timeline-marker" style="border-color: var(--bs-success) !important; {% if not own.end_date %}background: var(--bs-success);{% endif %}"></div>
<div class="ps-2">
<div class="fw-bold">{{ own.customer_name or own.owner_type }}</div>
<div class="text-muted small">
{{ own.start_date }}
{% if own.end_date %} - {{ own.end_date }}{% else %} <span class="badge bg-success py-0">Nuværende</span>{% endif %}
</div>
{% if own.notes %}<div class="text-muted small fst-italic mt-1">"{{ own.notes }}"</div>{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted ps-4">Ingen historik.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Files -->
<div class="tab-pane fade" id="files" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row g-3">
{% if attachments %}
{% for att in attachments %}
<div class="col-md-3 col-sm-6">
<div class="p-3 border rounded text-center bg-light h-100 position-relative">
<div class="display-6 mb-2">📎</div>
<div class="text-truncate fw-bold mb-1" title="{{ att.file_name }}">{{ att.file_name }}</div>
<div class="small text-muted">{{ att.uploaded_at }}</div>
<a href="#" class="stretched-link" onclick="alert('Download not implemented yet')"></a>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12 text-center py-5 text-muted">
<i class="bi bi-files display-4 opacity-25"></i>
<p class="mt-3">Ingen filer vedhæftet</p>
<button class="btn btn-sm btn-outline-primary" onclick="alert('Upload funktion kommer snart')">Upload fil</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Tab: Notes -->
<div class="tab-pane fade" id="notes" role="tabpanel">
<div class="card shadow-sm border-0">
<div class="card-body">
<h6 class="mb-3">Noter</h6>
<div class="p-3 bg-light rounded border">
{% if hardware.notes %}
<div style="white-space: pre-wrap;">{{ hardware.notes }}</div>
{% else %}
<span class="text-muted fst-italic">Ingen noter tilføjet...</span>
{% endif %}
</div>
<div class="mt-3 text-end">
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-sm btn-outline-primary">Rediger Noter</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal for Owner -->
<div class="modal fade" id="ownerModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Skift Ejer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="/hardware/{{ hardware.id }}/owner" method="post">
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-bold">Vælg kunde</label>
<input
type="text"
id="ownerCustomerSearch"
class="form-control mb-2"
placeholder="🔍 Søg virksomhed..."
autocomplete="off"
>
<select id="ownerCustomerSelect" name="owner_customer_id" class="form-select" required>
<option value="">-- Vælg kunde --</option>
{% for customer in owner_customers %}
<option value="{{ customer.id }}" {% if hardware.current_owner_customer_id == customer.id %}selected{% endif %}>
{{ customer.navn }}
</option>
{% endfor %}
</select>
<div id="ownerCustomerHelp" class="form-text">Søg og vælg virksomhed.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Vælg kontaktperson</label>
<input
type="text"
id="ownerContactSearch"
class="form-control mb-2"
placeholder="🔍 Søg kontaktperson..."
autocomplete="off"
>
<select
id="ownerContactSelect"
name="owner_contact_id"
class="form-select"
data-current-owner-contact-id="{{ owner_contact_ns.contact.contact_id if owner_contact_ns.contact else '' }}"
required
>
<option value="">-- Vælg kontaktperson --</option>
</select>
<div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div>
</div>
<div class="mb-3">
<label class="form-label">Note (valgfri)</label>
<textarea class="form-control" name="notes" rows="3" placeholder="F.eks. Overdraget til ny kunde"></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="submit" class="btn btn-success">Gem Ejer</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal for Location -->
<div class="modal fade" id="locationModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Skift Lokation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="/hardware/{{ hardware.id }}/location" method="post">
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-bold">Vælg ny lokation</label>
<input type="text" class="form-control mb-2" id="locationSearchInput" placeholder="🔍 Søg efter lokation..." autocomplete="off">
<div class="list-group border rounded" id="locationList" style="max-height: 350px; overflow-y: auto; background: #fff;">
{% macro render_location_option(node, depth) %}
<div class="location-item-container" data-location-name="{{ node.name | lower }}">
<label class="list-group-item list-group-item-action cursor-pointer border-0 py-1 px-2" style="padding-left: {{ depth * 20 + 10 }}px !important;">
<div class="d-flex align-items-center">
{% if node.children %}
<i class="bi bi-caret-right-fill me-1 text-muted toggle-children" style="cursor: pointer; font-size: 0.8rem;" onclick="toggleLocationChildren(event, '{{ node.id }}')"></i>
{% else %}
<i class="bi bi-dot me-1 text-muted" style="width: 12px;"></i>
{% endif %}
<input class="form-check-input me-2 mt-0" type="radio" name="location_id" value="{{ node.id }}" required>
<div>
<span class="location-name fw-normal">{{ node.name }}</span>
<small class="text-muted ms-1" style="font-size: 0.75rem;">({{ node.location_type }})</small>
</div>
</div>
</label>
{% if node.children %}
<div class="children-container collapsed" id="children-{{ node.id }}" style="display: none;">
{% for child in node.children %}
{{ render_location_option(child, depth + 1) }}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{% for node in location_tree %}
{{ render_location_option(node, 0) }}
{% endfor %}
<div id="noResults" class="p-3 text-center text-muted" style="display: none;">
Ingen lokationer fundet matching din søgning
</div>
</div>
<div class="form-text mt-2">
Kan du ikke finde lokationen? <a href="/locations" target="_blank" class="text-decoration-none"><i class="bi bi-plus-circle"></i> Opret ny lokation</a>
</div>
</div>
<div class="mb-3">
<label class="form-label">Noter til flytning</label>
<textarea class="form-control" name="notes" rows="3" placeholder="F.eks. Flyttet ifm. nyansættelse"></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="submit" class="btn btn-primary">Gem Ændringer</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Contact Modal -->
<div class="modal fade" id="addContactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tilføj Kontaktperson</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="/hardware/{{ hardware.id }}/contacts/add">
<div class="modal-body">
{% if available_contacts %}
<div class="mb-3">
<label class="form-label">Vælg Kontakt</label>
<select name="contact_id" class="form-select" required>
<option value="">-- Vælg --</option>
{% for contact in available_contacts %}
<option value="{{ contact.id }}">
{{ contact.first_name }} {{ contact.last_name }}
{% if contact.email %}({{ contact.email }}){% endif %}
</option>
{% endfor %}
</select>
<div class="form-text">Viser kun personer fra {{ hardware.customer_name if hardware.customer_name else 'kunden' }} som ikke allerede er tilknyttet.</div>
</div>
<input type="hidden" name="role" value="user">
<input type="hidden" name="source" value="manual">
{% else %}
<div class="alert alert-warning">
Ingen tilgængelige kontakter fundet for denne kunde.
<br><small>Opret først kontakter under kunden.</small>
</div>
{% endif %}
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
{% if available_contacts %}
<button type="submit" class="btn btn-primary">Tilføj</button>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
async function syncEsetData() {
try {
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/sync-eset`, { method: 'POST' });
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Request failed');
}
location.reload();
} catch (err) {
alert(`ESET opdatering fejlede: ${err.message}`);
}
}
// Tree Toggle Function
function toggleLocationChildren(event, nodeId) {
event.preventDefault();
event.stopPropagation(); // Prevent triggering the radio selection
const container = document.getElementById('children-' + nodeId);
const icon = event.target;
if (container.style.display === 'none') {
container.style.display = 'block';
icon.classList.remove('bi-caret-right-fill');
icon.classList.add('bi-caret-down-fill');
} else {
container.style.display = 'none';
icon.classList.remove('bi-caret-down-fill');
icon.classList.add('bi-caret-right-fill');
}
}
// Location Search Filter with Tree Support
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('locationSearchInput');
if (searchInput) {
searchInput.addEventListener('keyup', function() {
const filter = this.value.toLowerCase().trim();
const containers = document.querySelectorAll('.location-item-container');
let matchFound = false;
// Reset visualization if cleared
if (filter === "") {
// Show top-level only, collapse others check
document.querySelectorAll('.children-container').forEach(el => el.style.display = 'none');
document.querySelectorAll('.toggle-children').forEach(el => {
el.classList.remove('bi-caret-down-fill');
el.classList.add('bi-caret-right-fill');
});
containers.forEach(c => c.style.display = "");
document.getElementById('noResults').style.display = 'none';
return;
}
// First pass: Find direct matches
containers.forEach(container => {
const name = container.getAttribute('data-location-name');
if (name.includes(filter)) {
container.classList.add('match');
matchFound = true;
} else {
container.classList.remove('match');
}
});
// Second pass: Show/Hide based on matches and hierarchy
containers.forEach(container => {
// If this container is a match, or contains a match inside it
const isMatch = container.classList.contains('match');
const hasChildMatch = container.querySelectorAll('.match').length > 0;
if (isMatch || hasChildMatch) {
container.style.display = 'block';
// If it has children that matched, expand it to show them
if (hasChildMatch) {
const childContainer = container.querySelector('.children-container');
if (childContainer) {
childContainer.style.display = 'block';
const toggle = container.querySelector('.toggle-children');
if (toggle) {
toggle.classList.remove('bi-caret-right-fill');
toggle.classList.add('bi-caret-down-fill');
}
}
}
} else {
if (isMatch || hasChildMatch) {
container.style.display = 'block';
} else {
container.style.display = 'none';
}
}
});
document.getElementById('noResults').style.display = matchFound ? 'none' : 'block';
});
// Focus search field when modal opens
const locationModal = document.getElementById('locationModal');
locationModal.addEventListener('shown.bs.modal', function () {
searchInput.focus();
});
}
});
async function deleteHardware() {
if (!confirm('Er du sikker på at du vil slette dette hardware?')) {
return;
}
try {
const response = await fetch('/api/v1/hardware/{{ hardware.id }}', {
method: 'DELETE'
});
if (response.ok) {
alert('Hardware slettet!');
window.location.href = '/hardware';
} else {
alert('Fejl ved sletning af hardware');
}
} catch (error) {
alert('Fejl ved sletning: ' + error.message);
}
}
// Initialize Tags
document.addEventListener('DOMContentLoaded', function() {
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
const ownerContactSearch = document.getElementById('ownerContactSearch');
const ownerContactSelect = document.getElementById('ownerContactSelect');
const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
const ownerContactHelp = document.getElementById('ownerContactHelp');
let ownerContactsCache = [];
const initialOwnerCustomerOptions = ownerCustomerSelect
? Array.from(ownerCustomerSelect.options).map(option => ({
value: option.value,
label: option.textContent,
selected: option.selected
}))
: [];
let ownerCustomerSearchTimeout;
function renderOwnerCustomerOptions(items, keepValue = null) {
if (!ownerCustomerSelect) {
return;
}
ownerCustomerSelect.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = '-- Vælg kunde --';
ownerCustomerSelect.appendChild(placeholder);
(items || []).forEach(item => {
const option = document.createElement('option');
option.value = String(item.id);
option.textContent = item.name || item.navn || '';
ownerCustomerSelect.appendChild(option);
});
if (keepValue) {
ownerCustomerSelect.value = String(keepValue);
}
}
function restoreInitialOwnerCustomers() {
if (!ownerCustomerSelect) {
return;
}
ownerCustomerSelect.innerHTML = '';
initialOwnerCustomerOptions.forEach(item => {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.label;
if (item.selected) {
option.selected = true;
}
ownerCustomerSelect.appendChild(option);
});
}
async function searchOwnerCustomersRemote(query) {
if (!ownerCustomerSelect) {
return;
}
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search request failed');
}
const rows = await response.json();
const currentValue = ownerCustomerSelect.value;
renderOwnerCustomerOptions(rows || [], currentValue);
if (ownerCustomerHelp) {
ownerCustomerHelp.textContent = rows && rows.length
? `Viser ${rows.length} virksomhed(er).`
: 'Ingen virksomheder matcher søgningen.';
}
} catch (error) {
if (ownerCustomerHelp) {
ownerCustomerHelp.textContent = 'Søgning fejlede. Prøv igen.';
}
}
}
function filterOwnerCustomers() {
if (!ownerCustomerSearch || !ownerCustomerSelect) {
return;
}
const filter = ownerCustomerSearch.value.toLowerCase().trim();
if (filter.length >= 2) {
clearTimeout(ownerCustomerSearchTimeout);
ownerCustomerSearchTimeout = setTimeout(() => {
searchOwnerCustomersRemote(filter);
}, 250);
return;
}
restoreInitialOwnerCustomers();
const options = Array.from(ownerCustomerSelect.options);
let visibleCount = 0;
options.forEach((option, index) => {
if (index === 0) {
option.hidden = false;
return;
}
const label = (option.textContent || '').toLowerCase();
const isVisible = !filter || label.includes(filter);
option.hidden = !isVisible;
if (isVisible) {
visibleCount += 1;
}
});
const selectedOption = ownerCustomerSelect.selectedOptions[0];
if (selectedOption && selectedOption.hidden) {
ownerCustomerSelect.value = '';
}
if (ownerCustomerHelp) {
if (visibleCount === 0) {
ownerCustomerHelp.textContent = 'Ingen virksomheder matcher søgningen.';
} else {
ownerCustomerHelp.textContent = `Viser ${visibleCount} virksomhed(er).`;
}
}
}
async function loadOwnerContactsForCustomer(customerId) {
if (!ownerContactSelect) {
return;
}
ownerContactsCache = [];
ownerContactSelect.innerHTML = '<option value="">-- Vælg kontaktperson --</option>';
if (!customerId) {
ownerContactSelect.disabled = true;
if (ownerContactHelp) {
ownerContactHelp.textContent = 'Vælg først virksomhed.';
}
return;
}
try {
const response = await fetch(`/api/v1/customers/${encodeURIComponent(customerId)}/contacts`);
if (!response.ok) {
throw new Error('Failed to load contacts');
}
const rows = await response.json();
ownerContactsCache = rows || [];
ownerContactSelect.disabled = !ownerContactsCache.length;
filterOwnerContactsSearch();
if (ownerContactHelp) {
ownerContactHelp.textContent = ownerContactsCache.length
? `Viser ${ownerContactsCache.length} kontaktperson(er) for valgt virksomhed.`
: 'Ingen kontaktpersoner fundet for valgt virksomhed.';
}
} catch (error) {
ownerContactSelect.disabled = true;
ownerContactsCache = [];
if (ownerContactHelp) {
ownerContactHelp.textContent = 'Kunne ikke hente kontaktpersoner. Prøv igen.';
}
}
}
function filterOwnerContactsSearch() {
if (!ownerContactSelect || !ownerContactSearch) {
return;
}
const filter = ownerContactSearch.value.toLowerCase().trim();
const preferredContactId = ownerContactSelect.getAttribute('data-current-owner-contact-id');
const currentValue = ownerContactSelect.value;
const filteredContacts = ownerContactsCache.filter(contact => {
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim().toLowerCase();
const email = (contact.email || '').toLowerCase();
const phone = (contact.phone || '').toLowerCase();
return !filter || fullName.includes(filter) || email.includes(filter) || phone.includes(filter);
});
ownerContactSelect.innerHTML = '<option value="">-- Vælg kontaktperson --</option>';
filteredContacts.forEach(contact => {
const option = document.createElement('option');
option.value = String(contact.id);
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
option.textContent = contact.email ? `${fullName} (${contact.email})` : fullName;
if (
(currentValue && String(contact.id) === String(currentValue)) ||
(!currentValue && preferredContactId && String(contact.id) === String(preferredContactId))
) {
option.selected = true;
}
ownerContactSelect.appendChild(option);
});
if (ownerContactHelp) {
if (ownerContactSelect.disabled) {
ownerContactHelp.textContent = 'Vælg først virksomhed.';
} else if (filteredContacts.length === 0) {
ownerContactHelp.textContent = 'Ingen kontaktpersoner matcher søgningen.';
} else {
ownerContactHelp.textContent = `Viser ${filteredContacts.length} kontaktperson(er) for valgt virksomhed.`;
}
}
}
if (ownerCustomerSelect && ownerContactSelect) {
ownerCustomerSelect.addEventListener('change', function() {
ownerContactSelect.setAttribute('data-current-owner-contact-id', '');
loadOwnerContactsForCustomer(ownerCustomerSelect.value);
});
if (ownerCustomerSearch) {
ownerCustomerSearch.addEventListener('input', function() {
filterOwnerCustomers();
});
}
if (ownerContactSearch) {
ownerContactSearch.addEventListener('input', filterOwnerContactsSearch);
}
filterOwnerCustomers();
loadOwnerContactsForCustomer(ownerCustomerSelect.value);
}
if (window.renderEntityTags) {
window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags');
}
// Set default context for keyboard shortcuts (Option+Shift+T)
if (window.setTagPickerContext) {
window.setTagPickerContext('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'));
}
});
</script>
{% endblock %}