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

738 lines
34 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>
/* Nordic Top / Header Styling */
.page-header {
background: linear-gradient(135deg, var(--accent) 0%, #0b3a5b 100%);
color: white;
padding: 2rem 0 3rem;
margin-bottom: -2rem; /* Overlap with content */
border-radius: 0 0 12px 12px; /* Slight curve at bottom */
}
.page-header h1 {
font-weight: 700;
font-size: 2rem;
margin: 0;
}
.page-header .breadcrumb {
background: transparent;
padding: 0;
margin-bottom: 0.5rem;
}
.page-header .breadcrumb-item,
.page-header .breadcrumb-item a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 0.9rem;
}
.page-header .breadcrumb-item.active {
color: white;
}
/* Content Styling */
.main-content {
position: relative;
z-index: 10;
padding-bottom: 3rem;
}
.card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
margin-bottom: 1.5rem;
transition: transform 0.2s;
}
.card-header {
background: white;
border-bottom: 1px solid rgba(0,0,0,0.05);
padding: 1.25rem 1.5rem;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 12px 12px 0 0 !important;
}
.card-title-text {
color: var(--accent);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0,0,0,0.03);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
}
.info-value {
color: var(--text-primary);
font-weight: 600;
text-align: right;
}
/* Quick Action Cards */
.action-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
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;
color: var(--text-secondary);
}
.action-card:hover {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
transform: translateY(-2px);
}
.action-card i {
font-size: 2rem;
margin-bottom: 0.5rem;
}
/* Timeline */
.timeline {
position: relative;
padding-left: 2rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.5rem;
top: 0;
bottom: 0;
width: 2px;
background: #e9ecef;
}
.timeline-item {
position: relative;
padding-bottom: 1.5rem;
}
.timeline-marker {
position: absolute;
left: -2rem;
top: 0.2rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: white;
border: 2px solid var(--accent);
}
.timeline-item.active .timeline-marker {
background: #28a745;
border-color: #28a745;
}
.icon-box {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 1.5rem;
margin-right: 1rem;
}
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.1); color: #0d6efd; }
.bg-soft-success { background-color: rgba(25, 135, 84, 0.1); color: #198754; }
.bg-soft-warning { background-color: rgba(255, 193, 7, 0.1); color: #ffc107; }
.bg-soft-info { background-color: rgba(13, 202, 240, 0.1); color: #0dcaf0; }
</style>
{% endblock %}
{% block content %}
<!-- Custom Nordic Blue Header -->
<div class="page-header">
<div class="container-fluid px-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Forside</a></li>
<li class="breadcrumb-item"><a href="/hardware">Hardware</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ hardware.serial_number or 'Detail' }}</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="me-3" style="font-size: 2.5rem;">
{% if hardware.asset_type == 'pc' %}🖥️
{% elif hardware.asset_type == 'laptop' %}💻
{% elif hardware.asset_type == 'printer' %}🖨️
{% elif hardware.asset_type == 'skærm' %}🖥️
{% elif hardware.asset_type == 'telefon' %}📱
{% elif hardware.asset_type == 'server' %}🗄️
{% elif hardware.asset_type == 'netværk' %}🌐
{% else %}📦
{% endif %}
</div>
<div>
<h1>{{ hardware.brand or 'Unknown' }} {{ hardware.model or '' }}</h1>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="badge bg-white text-dark border">{{ hardware.serial_number or 'Ingen serienummer' }}</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 %}">
{{ hardware.status|replace('_', ' ')|title }}
</span>
</div>
</div>
</div>
<div class="d-flex gap-2">
<a href="/hardware/{{ hardware.id }}/edit" class="btn btn-light text-primary fw-medium shadow-sm">
<i class="bi bi-pencil me-1"></i> Rediger
</a>
<button onclick="deleteHardware()" class="btn btn-danger text-white fw-medium shadow-sm" style="background-color: rgba(220, 53, 69, 0.9);">
<i class="bi bi-trash me-1"></i> Slet
</button>
</div>
</div>
</div>
</div>
<div class="container-fluid px-4 main-content">
<div class="row">
<!-- Left Column: Key Info & Relations -->
<div class="col-lg-4">
<!-- Key Details Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-info-circle"></i> Stamdata</div>
</div>
<div class="card-body pt-0">
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">{{ hardware.asset_type|title }}</span>
</div>
{% if hardware.internal_asset_id %}
<div class="info-row">
<span class="info-label">Intern 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 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 Udløb</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>
<!-- Tags Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-tags"></i> Tags</div>
<button class="btn btn-sm btn-outline-primary" onclick="window.showTagPicker('hardware', {{ hardware.id }}, () => window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'))">
<i class="bi bi-plus-lg"></i> Tilføj
</button>
</div>
<div class="card-body">
<div id="hardware-tags" class="d-flex flex-wrap">
<!-- Tags loaded via JS -->
<div class="text-center w-100 py-2">
<span class="spinner-border spinner-border-sm text-muted"></span>
</div>
</div>
</div>
</div>
<!-- Current Location Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-geo-alt"></i> Nuværende Lokation</div>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-arrow-left-right"></i> Skift
</button>
</div>
<div class="card-body">
{% if locations and locations|length > 0 %}
{% set current_loc = locations[0] %}
{% if not current_loc.end_date %}
<div class="d-flex align-items-center">
<div class="icon-box bg-soft-primary">
<i class="bi bi-building"></i>
</div>
<div>
<h5 class="mb-1">{{ current_loc.location_name or 'Ukendt' }}</h5>
<small class="text-muted">Siden {{ current_loc.start_date }}</small>
</div>
</div>
{% if current_loc.notes %}
<div class="mt-3 p-2 bg-light rounded small text-muted">
<i class="bi bi-card-text me-1"></i> {{ current_loc.notes }}
</div>
{% endif %}
{% else %}
<div class="text-center text-muted py-3">
<i class="bi bi-geo-alt" style="font-size: 2rem; opacity: 0.5;"></i>
<p class="mt-2 text-primary">Ingen aktiv lokation</p>
</div>
{% endif %}
{% else %}
<div class="text-center py-2">
<p class="text-muted mb-3">Hardwaret er ikke tildelt en lokation</p>
<button class="btn btn-primary w-100" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-plus-circle me-1"></i> Tildel Lokation
</button>
</div>
{% endif %}
</div>
</div>
<!-- Current Owner Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card-title-text"><i class="bi bi-person"></i> Nuværende Ejer</div>
</div>
<div class="card-body">
{% if ownership and ownership|length > 0 %}
{% set current_own = ownership[0] %}
{% if not current_own.end_date %}
<div class="d-flex align-items-center">
<div class="icon-box bg-soft-success">
<i class="bi bi-person-badge"></i>
</div>
<div>
<h5 class="mb-1">
{{ current_own.customer_name or current_own.owner_type|title }}
</h5>
<small class="text-muted">Siden {{ current_own.start_date }}</small>
</div>
</div>
{% else %}
<p class="text-muted text-center py-2">Ingen aktiv ejer registreret</p>
{% endif %}
{% else %}
<p class="text-muted text-center py-2">Ingen ejerhistorik</p>
{% endif %}
</div>
</div>
</div>
<!-- Right Column: Quick Add & History -->
<div class="col-lg-8">
<!-- Quick Actions Grid -->
<div class="row mb-4">
<div class="col-md-3">
<div class="action-card" onclick="alert('Funktion: Opret Sag til dette hardware')">
<i class="bi bi-ticket-perforated"></i>
<div>Opret Sag</div>
</div>
</div>
<div class="col-md-3">
<div class="action-card" data-bs-toggle="modal" data-bs-target="#locationModal">
<i class="bi bi-geo-alt"></i>
<div>Skift Lokation</div>
</div>
</div>
<div class="col-md-3">
<!-- Link to create new location, pre-filled? Or just general create -->
<a href="/app/locations" class="text-decoration-none">
<div class="action-card">
<i class="bi bi-building-add"></i>
<div>Ny Lokation</div>
</div>
</a>
</div>
<div class="col-md-3">
<div class="action-card" onclick="alert('Funktion: Upload bilag')">
<i class="bi bi-paperclip"></i>
<div>Tilføj Bilag</div>
</div>
</div>
</div>
<!-- Tabs Section -->
<div class="card">
<div class="card-header p-0 border-bottom-0">
<ul class="nav nav-tabs ps-3 pt-3 pe-3 w-100" id="hwTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">Historik</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="cases-tab" data-bs-toggle="tab" data-bs-target="#cases" type="button" role="tab">Sager ({{ cases|length }})</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">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">Noter</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="hwTabsContent">
<!-- History Tab -->
<div class="tab-pane fade show active" id="history" role="tabpanel">
<h6 class="text-secondary text-uppercase small fw-bold mb-4">Kombineret Historik</h6>
<div class="timeline">
<!-- Interleave items visually? For now just dump both lists or keep separate sections inside tab -->
<!-- Let's show Location History first -->
<div class="mb-4">
<strong class="d-block mb-3 text-primary"><i class="bi bi-geo-alt"></i> Placeringer</strong>
{% if locations %}
{% for loc in locations %}
<div class="timeline-item {% if not loc.end_date %}active{% endif %}">
<div class="timeline-marker"></div>
<div class="ms-2">
<div class="fw-bold">{{ loc.location_name or 'Ukendt' }} ({{ loc.start_date }} {% if loc.end_date %} - {{ loc.end_date }}{% else %}- nu{% endif %})</div>
{% if loc.notes %}<div class="text-muted small">{{ loc.notes }}</div>{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted fst-italic">Ingen lokations historik</p>
{% endif %}
</div>
<div class="mb-4">
<strong class="d-block mb-3 text-success"><i class="bi bi-person"></i> Ejerskab</strong>
{% if ownership %}
{% for own in ownership %}
<div class="timeline-item {% if not own.end_date %}active{% endif %}">
<div class="timeline-marker"></div>
<div class="ms-2">
<div class="fw-bold">{{ own.customer_name or own.owner_type }} ({{ own.start_date }} {% if own.end_date %} - {{ own.end_date }}{% else %}- nu{% endif %})</div>
{% if own.notes %}<div class="text-muted small">{{ own.notes }}</div>{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted fst-italic">Ingen ejerskabs historik</p>
{% endif %}
</div>
</div>
</div>
<!-- Cases Tab -->
<div class="tab-pane fade" id="cases" role="tabpanel">
{% if cases and cases|length > 0 %}
<div class="list-group list-group-flush">
{% for case in cases %}
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1 text-primary">{{ case.titel }}</h6>
<small>{{ case.created_at }}</small>
</div>
<div class="d-flex justify-content-between align-items-center mt-1">
<small class="text-muted">Status: {{ case.status }}</small>
<span class="badge bg-light text-dark border">ID: {{ case.case_id }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-clipboard-check display-4 text-muted opacity-25"></i>
<p class="mt-3 text-muted">Ingen sager tilknyttet.</p>
<button class="btn btn-sm btn-outline-primary" onclick="alert('Opret Sag')">Opret ny sag</button>
</div>
{% endif %}
</div>
<!-- Attachments Tab -->
<div class="tab-pane fade" id="files" role="tabpanel">
<div class="row g-3">
{% if attachments %}
{% for att in attachments %}
<div class="col-md-4 col-sm-6">
<div class="p-3 border rounded text-center bg-light h-100">
<div class="display-6 mb-2">📎</div>
<div class="text-truncate fw-bold">{{ att.file_name }}</div>
<div class="small text-muted">{{ att.uploaded_at }}</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12 text-center py-4 text-muted">
Ingen filer vedhæftet
</div>
{% endif %}
</div>
</div>
<!-- Notes Tab -->
<div class="tab-pane fade" id="notes" role="tabpanel">
<div class="p-3 bg-light rounded border">
{% if hardware.notes %}
{{ hardware.notes }}
{% else %}
<span class="text-muted fst-italic">Ingen noter...</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</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>
{% endblock %}
{% block extra_js %}
<script>
// 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 {
// Hide if not a match and doesn't contain matches
// BUT be careful not to hide if a parent matched?
// Actually, search usually filters down. If parent matches, should we show all children?
// Let's stick to showing matches and path to matches.
// Important: logic is tricky with flat recursion vs nested DOM
// My macro structure is nested: .location-item-container contains children-container which contains .location-item-container
// So `container.style.display = 'block'` on a parent effectively shows the wrapper.
// If I am not a match, and I have no children that are matches...
// But wait, if my parent is a match, do I show up?
// Usually "Search" filters items out.
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() {
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 %}