Quelltext des aktuellen Standes
<?php
$this->layout('layouts/main', [
'page_title' => $entry['name'],
'current_route' => 'licenses',
'full_width' => true,
// Die Zuordnung nutzt die zentrale Personensuche.
'extra_css' => ['/assets/css/person-search.css'],
'extra_js' => ['/assets/js/person-search.js'],
]);
require_once dirname(__DIR__) . '/partials/_attachment_helpers.php';
$e = $entry;
$contractsOn = ($modules['contracts'] ?? true) === true;
$additionalFiles = $e['additional_files'] ?? [];
$children = $e['children'] ?? [];
$parent = $e['parent'] ?? null;
$statusCfg = [
'active' => ['label' => 'Aktiv', 'cls' => 'status-pill--success'],
'trial' => ['label' => 'Test', 'cls' => 'status-pill--info'],
'expired' => ['label' => 'Abgelaufen', 'cls' => 'status-pill--danger'],
'cancelled' => ['label' => 'Gekündigt', 'cls' => 'status-pill--neutral'],
];
$typeColors = [
'subscription' => '#1d4ed8', 'perpetual' => '#15803d',
'volume' => '#7c3aed', 'oem' => '#374151',
'open_source' => '#0891b2', 'trial' => '#d97706',
];
$stCfg = $statusCfg[$e['status']] ?? $statusCfg['active'];
$tc = $typeColors[$e['type']] ?? '#6b7280';
$seatsUsed = (int)$e['seats_used'];
$seatsTot = $e['seats'] !== null ? (int)$e['seats'] : null;
$seatPct = ($seatsTot > 0) ? min(100, (int)round($seatsUsed * 100 / $seatsTot)) : 0;
$seatWarn = $seatPct >= 100 ? 'bg-danger' : ($seatPct >= 80 ? 'bg-warning' : 'bg-info');
$daysLeft = null;
if (!empty($e['valid_until'])) {
$daysLeft = (int)ceil((strtotime($e['valid_until']) - time()) / 86400);
}
$expiryBox = $daysLeft !== null
? ($daysLeft < 0 ? 'bg-danger' : ($daysLeft <= 30 ? 'bg-warning' : 'bg-secondary'))
: 'bg-secondary';
?>
<style>
/* ════════════════════════════════════════════════════════
License Show – prefix: lvs-
(CSS cloned from cs- / contracts show pattern)
════════════════════════════════════════════════════════ */
/* ── Layout ────────────────────────────────────────────── */
.lvs-layout { display: grid; grid-template-columns: 1fr 330px; gap: 1.5rem; align-items: start; }
.lvs-main { display: flex; flex-direction: column; min-width: 0; }
.lvs-sidebar { display: flex; flex-direction: column; position: sticky; top: 1rem; }
/* Titel mit Icon (modul-spezifischer Zusatz zur Standard-Detail-Karte) */
.lvs-layout .detail-card-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; }
.lvs-layout .detail-card-title svg { color: var(--gray-400); flex-shrink: 0; }
.lvs-text { margin: 0; font-size: 0.9rem; color: var(--gray-700); line-height: 1.65; white-space: pre-wrap; }
.lvs-empty { margin: 0; font-size: 0.85rem; color: var(--gray-400); }
/* ── File list ─────────────────────────────────────────── */
.lvs-file-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.625rem 0.75rem; background: var(--gray-50);
border-radius: 0.25rem; border: 1px solid var(--gray-100);
}
.lvs-file-info { flex: 1; min-width: 0; }
.lvs-file-name { color: var(--blue-600); font-size: 0.84rem; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lvs-file-meta { font-size: 0.7rem; color: var(--gray-400); display: block; }
.lvs-file-dl, .lvs-file-del {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
border-radius: 0.375rem; border: none; cursor: pointer; flex-shrink: 0;
transition: all 0.15s; text-decoration: none;
}
.lvs-file-dl { color: var(--gray-500); background: var(--gray-200); }
.lvs-file-dl:hover { background: var(--blue-100); color: var(--blue-600); text-decoration: none; }
.lvs-file-del { color: var(--gray-400); background: var(--gray-100); }
.lvs-file-del:hover { background: var(--red-100); color: var(--red-600); }
/* ── Contract cards in main ────────────────────────────── */
.lvs-contract-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.625rem 0.75rem; background: var(--gray-50);
border-radius: 0.25rem; border: 1px solid var(--gray-100);
text-decoration: none; color: inherit; transition: border-color .15s;
}
.lvs-contract-row:hover { border-color: var(--color-primary-soft); text-decoration: none; }
.lvs-contract-info { flex: 1; min-width: 0; }
.lvs-contract-name { font-size: 0.84rem; font-weight: 500; color: var(--blue-600); display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lvs-contract-meta { font-size: 0.7rem; color: var(--gray-400); display: block; }
/* ── Detail DL ─────────────────────────────────────────── */
.lvs-dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; align-items: baseline; }
.lvs-dl dt { font-size: 0.72rem; color: var(--gray-400); text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.lvs-dl dd { margin: 0; font-size: 0.875rem; color: var(--gray-900); font-weight: 500; }
/* ── Seat bar ────────────────────────────────────────────── */
.lvs-seat-track { height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden; margin-top: 4px; }
.lvs-seat-fill { height: 100%; border-radius: 3px; }
/* ── License key ───────────────────────────────────────── */
.lvs-key-box {
font-family: 'Courier New', monospace; font-size: 0.78rem;
background: var(--code-dark-bg); color: var(--code-dark-text);
padding: 0.6rem 0.75rem; border-radius: 4px;
word-break: break-all; letter-spacing: 0.04em; margin-bottom: 0.5rem;
}
.lvs-key-masked { filter: blur(5px); user-select: none; transition: filter .2s; }
.lvs-key-actions { display: flex; gap: 0.375rem; }
.lvs-key-btn {
flex: 1; padding: 0.3rem 0; border: 1px solid var(--gray-200); border-radius: 4px;
background: var(--gray-50); cursor: pointer; font-size: 0.75rem; color: var(--gray-700);
transition: all .15s;
}
.lvs-key-btn:hover { background: var(--gray-100); border-color: var(--gray-300); }
/* ── Responsive ────────────────────────────────────────── */
@media (max-width: 1100px) { .lvs-layout { grid-template-columns: 1fr 300px; } }
@media (max-width: 900px) { .lvs-layout { grid-template-columns: 1fr; } .lvs-sidebar { position: static; } }
@media (max-width: 480px) { .lvs-dl { grid-template-columns: 1fr; gap: 0.1rem; } .lvs-dl dt { margin-top: 0.625rem; } .lvs-dl dt:first-child { margin-top: 0; } }
</style>
<div class="lvs-wrap">
<!-- Content Header -->
<div class="content-header">
<div>
<h1>
<span class="status-pill <?= $stCfg['cls'] ?>" style="vertical-align:middle;margin-right:0.5rem;"><?= $stCfg['label'] ?></span>
<?= $this->e($e['name']) ?>
<?php $extSrc = $e['external_source'] ?? 'manual'; ?>
<?php if ($extSrc === 'entra'): ?>
<span style="display:inline-block;background:var(--sky-100);color:var(--sky-800);border:1px solid var(--sky-200);border-radius:4px;font-size:0.7rem;padding:0.15rem 0.5rem;margin-left:0.4rem;vertical-align:middle;font-weight:500;"
title="Aus Microsoft Entra synchronisiert<?= !empty($e['last_synced_at']) ? ' am ' . date('d.m.Y H:i', strtotime($e['last_synced_at'])) : '' ?>">
<?= $this->te('☁ Entra') ?>
</span>
<?php endif; ?>
</h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item"><a href="/licenses"><?= $this->te('Lizenzen') ?></a></li>
<?php if ($parent): ?>
<li class="breadcrumb-item"><a href="/licenses/<?= (int)$parent['id'] ?>"><?= $this->e(mb_strimwidth($parent['name'], 0, 40, '…')) ?></a></li>
<?php endif; ?>
<li class="breadcrumb-item active"><?= $this->e(mb_strimwidth($e['name'], 0, 50, '…')) ?></li>
</ol>
</div>
<?php if ($can_manage): ?>
<div class="content-header-actions">
<a href="/licenses/<?= (int)$e['id'] ?>/edit" class="btn btn-default"><?= $this->te('Bearbeiten') ?></a>
<form action="/licenses/<?= (int)$e['id'] ?>/delete" method="POST" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="button" class="btn btn-danger"
onclick="itConfirmSubmit(this, 'Lizenz löschen?', 'Die Lizenz „<?= $this->e(addslashes($e['name'])) ?>“ wird mit allen Dateien unwiderruflich gelöscht.', 'Löschen')"><?= $this->te('Löschen') ?></button>
</form>
</div>
<?php endif; ?>
</div>
<!-- ═══ BODY ════════════════════════════════════════════════ -->
<div class="page-body">
<!-- KPI Small Boxes (schmal wie /inventory) -->
<div class="kpi-row">
<!-- Gültig bis -->
<?php if ($daysLeft !== null): ?>
<div class="kpi-col">
<div class="small-box <?= $expiryBox ?>">
<div class="inner">
<h3><?= date('d.m.Y', strtotime($e['valid_until'])) ?></h3>
<p><?= $this->t('Gültig bis') ?> <?= $daysLeft < 0 ? '(abgelaufen)' : '(in ' . $daysLeft . ' Tagen)' ?></p>
</div>
<div class="icon"><i class="fas fa-clock"></i></div>
</div>
</div>
<?php endif; ?>
<!-- Seats -->
<?php if ($seatsTot !== null): ?>
<div class="kpi-col">
<div class="small-box <?= $seatWarn ?>">
<div class="inner">
<h3><?= $seatsUsed ?> / <?= $seatsTot ?></h3>
<p><?= $this->t('Seats belegt') ?> (<?= $seatPct ?>%)</p>
</div>
<div class="icon"><i class="fas fa-users"></i></div>
</div>
</div>
<?php elseif ($seatsUsed > 0): ?>
<div class="kpi-col">
<div class="small-box bg-info">
<div class="inner">
<h3><?= $seatsUsed ?></h3>
<p><?= $this->te('Seats genutzt') ?></p>
</div>
<div class="icon"><i class="fas fa-users"></i></div>
</div>
</div>
<?php endif; ?>
<!-- Kosten -->
<?php if (!empty($e['cost_amount'])): ?>
<div class="kpi-col">
<div class="small-box bg-primary">
<div class="inner">
<h3><?= $this->money((float)$e['cost_amount'], 2) ?></h3>
<p><?= $this->e($cost_labels[$e['cost_interval'] ?? ''] ?? 'Kosten') ?></p>
</div>
<div class="icon"><i class="fas fa-euro-sign"></i></div>
</div>
</div>
<?php endif; ?>
</div>
<div class="lvs-layout">
<!-- ── HAUPTSPALTE ──────────────────────────────────── -->
<main class="lvs-main">
<!-- Notizen -->
<?php if (!empty($e['notes'])): ?>
<section class="detail-card">
<div class="detail-card-header">
<h2 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<?= $this->te('Notizen') ?>
</h2>
</div>
<div class="detail-card-body">
<p class="lvs-text"><?= $this->e($e['notes']) ?></p>
</div>
</section>
<?php endif; ?>
<!-- Zuordnung -->
<?php
$zuordnungen = $assignments ?? [];
$zugeordnet = (int)($assigned_count ?? 0);
// 'manual' heisst selbst gepflegt; jede andere Quelle fuehrt die
// Belegung selbst und erneuert sie bei jedem Lauf.
$abgeglichen = ($e['external_source'] ?? 'manual') !== 'manual';
// Die gepflegte Zahl und die Zuordnungen koennen auseinanderliegen:
// bei abgeglichenen Lizenzen kommt sie aus der Quelle, bei selbst
// gepflegten stammt sie aus der Zeit vor den Zuordnungen.
$weichtAb = $zugeordnet !== $seatsUsed;
?>
<section class="detail-card">
<div class="detail-card-header">
<h2 class="detail-card-title">
<i class="fas fa-user-check"></i>
<?= $this->t('Zugewiesen an') ?>
<?php if ($zugeordnet > 0): ?>
<span style="background:var(--color-primary-soft);color:var(--color-primary-strong);border-radius:9999px;font-size:0.7rem;font-weight:600;padding:0.1rem 0.4rem;"><?= $zugeordnet ?></span>
<?php endif; ?>
</h2>
</div>
<div class="detail-card-body">
<?php if ($weichtAb): ?>
<div class="alert alert-info">
<?php if ($abgeglichen): ?>
<?= $this->t('Hier sind <strong>{zugeordnet}</strong> Zuordnungen erfasst, die Lizenz meldet <strong>{belegt}</strong> belegte Plätze. Die gemeldete Zahl kommt aus dem Abgleich und bleibt maßgeblich; die Zuordnungen halten fest, wem der Platz bei euch zugedacht ist.', ['zugeordnet' => $this->e($zugeordnet), 'belegt' => $this->e($seatsUsed)]) ?>
<?php else: ?>
<?= $this->t('Hier sind <strong>{zugeordnet}</strong> Zuordnungen erfasst, hinterlegt sind <strong>{belegt}</strong> belegte Plätze.', ['zugeordnet' => $this->e($zugeordnet), 'belegt' => $this->e($seatsUsed)]) ?>
<?php if ($can_manage): ?>
<form method="POST" action="/licenses/<?= (int)$e['id'] ?>/seats-from-assignments" style="display:inline;margin-left:.4rem;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="btn btn-xs btn-default"><?= $this->te('Aus den Zuordnungen übernehmen') ?></button>
</form>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($zuordnungen === []): ?>
<div class="empty-state">
<span class="empty-icon"><i class="fas fa-user-check"></i></span>
<strong><?= $this->te('Noch niemandem zugewiesen') ?></strong>
<p><?= $this->te('Halte fest, welches Gerät oder welche Person einen Platz dieser Lizenz belegt.') ?></p>
</div>
<?php else: ?>
<div class="table-wrapper">
<table class="data-table">
<thead><tr><th scope="col"><?= $this->te('Ziel') ?></th><th scope="col"><?= $this->te('Herkunft') ?></th><th scope="col"><?= $this->te('Notiz') ?></th><th scope="col"><?= $this->te('Seit') ?></th><?php if ($can_manage): ?><th scope="col"></th><?php endif; ?></tr></thead>
<tbody>
<?php foreach ($zuordnungen as $z): ?>
<tr>
<td>
<?php if ($z['subject_type'] === 'asset'): ?>
<i class="fas fa-desktop" style="color:var(--gray-400);"></i>
<a class="table-link" href="/inventory/<?= (int)$z['subject_id'] ?>"><?= $this->e((string)($z['asset_name'] ?: $z['inventory_number'])) ?></a>
<?php if (!empty($z['inventory_number']) && !empty($z['asset_name'])): ?>
<span class="text-muted">(<?= $this->e((string)$z['inventory_number']) ?>)</span>
<?php endif; ?>
<?php else: ?>
<i class="fas fa-user" style="color:var(--gray-400);"></i>
<?= $this->e((string)$z['person_name']) ?>
<?php if (!empty($z['department'])): ?>
<span class="text-muted">(<?= $this->e((string)$z['department']) ?>)</span>
<?php endif; ?>
<?php endif; ?>
</td>
<td>
<?php if (($z['source'] ?? 'manual') === 'entra'): ?>
<span class="badge" title="<?= $this->te('Aus Microsoft Entra übernommen; wird beim nächsten Abgleich aktualisiert') ?>"><?= $this->te('Entra') ?></span>
<?php else: ?>
<span class="text-muted"><?= $this->te('Von Hand') ?></span>
<?php endif; ?>
</td>
<td><?= $this->e((string)($z['note'] ?? '')) ?></td>
<td><?= $this->e(date('d.m.Y', strtotime((string)$z['assigned_at']))) ?></td>
<?php if ($can_manage): ?>
<td style="text-align:right;">
<?php if (($z['source'] ?? 'manual') === 'entra'): ?>
<!-- Aufheben hätte keinen Bestand: Der nächste Abgleich trüge die
Zuordnung wieder ein. Geändert wird sie in Entra. -->
<span class="text-muted" style="font-size:.8rem;" title="<?= $this->te('Diese Zuordnung stammt aus Entra und wird dort geändert') ?>">—</span>
<?php else: ?>
<form method="POST" action="/licenses/<?= (int)$e['id'] ?>/assignments/<?= (int)$z['id'] ?>/delete" onsubmit="return confirm('Zuordnung aufheben?');">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="btn btn-xs btn-outline-danger" title="<?= $this->te('Zuordnung aufheben') ?>"><i class="fas fa-times"></i></button>
</form>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ($can_manage): ?>
<form method="POST" action="/licenses/<?= (int)$e['id'] ?>/assignments" style="margin-top:1rem;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="form-group">
<label for="lic-subject-type"><?= $this->te('Was soll den Platz belegen?') ?></label>
<select name="subject_type" class="form-control form-control-sm" id="lic-subject-type">
<option value="person"><?= $this->te('Person aus dem Verzeichnis') ?></option>
<option value="asset"><?= $this->te('Gerät aus dem Inventar') ?></option>
</select>
</div>
<input type="hidden" name="subject_id" id="lic-subject-id" value="">
<div class="form-group" id="lic-person-wrap">
<label><?= $this->te('Person') ?></label>
<input class="form-control form-control-sm person-search-input" type="text" id="lic-person-search"
placeholder="<?= $this->te('Name oder E-Mail suchen…') ?>" autocomplete="off"
data-person-search="/api/persons/search" data-ps-set="name" data-ps-fill-id="lic-subject-id">
</div>
<div class="form-group" id="lic-asset-wrap" style="display:none;">
<label><?= $this->te('Gerät') ?></label>
<input class="form-control form-control-sm" type="text" id="lic-asset-search" list="lic-asset-list"
placeholder="<?= $this->te('Name, Inventar- oder Seriennummer suchen…') ?>" autocomplete="off">
<datalist id="lic-asset-list"></datalist>
<small class="text-muted" id="lic-asset-hint"><?= $this->te('Mindestens zwei Zeichen eingeben, dann aus der Liste wählen.') ?></small>
</div>
<div class="form-group">
<label><?= $this->te('Notiz') ?> <span class="form-hint"><?= $this->te('(optional)') ?></span></label>
<input type="text" name="note" class="form-control form-control-sm" placeholder="<?= $this->te('z. B. Projektzuordnung') ?>">
</div>
<button type="submit" class="btn btn-primary btn-sm"><?= $this->te('Zuweisen') ?></button>
</form>
<?php endif; ?>
</div>
</section>
<!-- Software-Inventar: welches Programm zu dieser Lizenz gehört -->
<?php if (!empty($software_enabled)): ?>
<?php
$swZugeordnet = $linked_titles ?? [];
$swVorschlag = $title_suggestions ?? [];
$swGeraete = 0;
foreach ($swZugeordnet as $swT) { $swGeraete = max($swGeraete, (int)$swT['geraete']); }
?>
<section class="detail-card">
<div class="detail-card-header">
<h2 class="detail-card-title">
<i class="fas fa-box-open"></i>
<?= $this->t('Software-Inventar') ?>
<?php if (!empty($swZugeordnet)): ?>
<span class="badge"><?= count($swZugeordnet) ?></span>
<?php endif; ?>
</h2>
<a href="/licenses/abgleich" class="btn btn-default btn-xs"><?= $this->te('Abgleich ansehen') ?></a>
</div>
<div class="detail-card-body">
<?php if (empty($swZugeordnet)): ?>
<p class="text-muted" style="margin-top:0;font-size:.87rem;">
<?= $this->te('Noch kein Programm zugeordnet. Erst mit einer Zuordnung lässt sich sagen, ob die gekauften Plätze zur tatsächlichen Installation passen – geraten wird sie nicht.') ?>
</p>
<?php else: ?>
<div class="table-wrapper">
<table class="data-table">
<thead><tr><th scope="col"><?= $this->te('Programm') ?></th><th scope="col"><?= $this->te('Hersteller') ?></th><th scope="col"><?= $this->te('Geräte') ?></th><?php if ($can_manage): ?><th scope="col"></th><?php endif; ?></tr></thead>
<tbody>
<?php foreach ($swZugeordnet as $swT): ?>
<tr>
<td><a href="/software/<?= (int)$swT['id'] ?>" class="table-link"><?= $this->e((string)$swT['name']) ?></a></td>
<td class="text-muted"><?= $this->e((string)$swT['publisher']) ?: '—' ?></td>
<td><strong><?= (int)$swT['geraete'] ?></strong></td>
<?php if ($can_manage): ?>
<td style="text-align:right;">
<form method="POST" action="/licenses/<?= (int)$e['id'] ?>/software/<?= (int)$swT['id'] ?>/delete" onsubmit="return confirm('Zuordnung aufheben?');">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="btn btn-xs btn-outline-danger" title="<?= $this->te('Zuordnung aufheben') ?>"><i class="fas fa-times"></i></button>
</form>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
$swPlaetze = (int)($e['seats'] ?? 0);
$swUrteil = OpenNIT\Software\LicenseComparison::differenz([
'seats' => $swPlaetze, 'installationen' => $swGeraete,
]);
?>
<p style="margin:.75rem 0 0;">
<strong><?= $swGeraete ?></strong> <?= $this->te('Gerät(e) mit diesem Programm') ?>
<?php if ($swPlaetze > 0): ?>
gegenüber <strong><?= $swPlaetze ?></strong> <?= $this->te('Platz/Plätzen –') ?>
<span class="status-pill <?= $swUrteil['ton'] === 'fehlend' ? 'status-pill--danger' : ($swUrteil['ton'] === 'genau' ? 'status-pill--warning' : 'status-pill--success') ?>">
<?= $this->e($swUrteil['text']) ?>
</span>
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ($can_manage && !empty($swVorschlag)): ?>
<div style="margin-top:1rem;">
<div class="form-group" style="margin-bottom:.4rem;font-weight:600;font-size:.85rem;"><?= $this->te('Vorschläge aus dem Inventar') ?></div>
<p class="text-muted" style="margin:0 0 .5rem;font-size:.82rem;">
<?= $this->te('Dem Namen nach passend – bitte prüfen, bevor Sie übernehmen.') ?>
</p>
<?php foreach ($swVorschlag as $swV): ?>
<form method="POST" action="/licenses/<?= (int)$e['id'] ?>/software" style="display:inline-block;margin:0 .35rem .35rem 0;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="title_id" value="<?= (int)$swV['id'] ?>">
<button type="submit" class="btn btn-default btn-xs"
title="<?= $this->e((string)$swV['publisher']) ?> · auf <?= (int)$swV['geraete'] ?> Gerät(en)">
+ <?= $this->e((string)$swV['name']) ?>
<span class="text-muted">(<?= (int)$swV['geraete'] ?>)</span>
</button>
</form>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<!-- Lizenzdateien -->
<section class="detail-card">
<div class="detail-card-header">
<h2 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<?= $this->te('Lizenzdateien') ?>
<?php $totalFiles = (!empty($file_info) ? 1 : 0) + count($additionalFiles); ?>
<?php if ($totalFiles > 0): ?>
<span style="background:var(--color-primary-soft);color:var(--color-primary-strong);border-radius:9999px;font-size:0.7rem;font-weight:600;padding:0.1rem 0.4rem;"><?= $totalFiles ?></span>
<?php endif; ?>
</h2>
</div>
<div class="detail-card-body">
<?php if (!empty($additionalFiles)): ?>
<div style="display:flex;flex-direction:column;gap:0.4rem;">
<?php foreach ($additionalFiles as $af): ?>
<div class="lvs-file-row">
<div style="flex-shrink:0;width:28px;display:flex;align-items:center;justify-content:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="#15803d" stroke-width="2" width="20" height="20"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="lvs-file-info">
<span class="lvs-file-name"><?= $this->e($af['file_name']) ?></span>
<span class="lvs-file-meta"><?= !empty($af['file_size']) ? round($af['file_size']/1024,1).' KB' : '' ?><?= !empty($af['uploaded_by_name']) ? ' · '.$this->e($af['uploaded_by_name']) : '' ?></span>
</div>
<?php
// PDFs/Bilder in der gemeinsamen Lightbox öffnen (statt Download).
$afUrl = '/licenses/' . (int)$e['id'] . '/files/' . (int)$af['id'];
echo att_view_link($afUrl, $af['file_name'], $af['mime_type'] ?? '', 'lvs-file-dl');
?>
<a href="<?= $afUrl ?>?download=1" class="lvs-file-dl" title="<?= $this->te('Herunterladen') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</a>
<?php if ($can_manage): ?>
<form action="/licenses/<?= (int)$e['id'] ?>/files/<?= (int)$af['id'] ?>/delete" method="POST" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="button" class="lvs-file-del" title="<?= $this->te('Löschen') ?>"
onclick="itConfirmSubmit(this, 'Datei löschen?', 'Die Datei „<?= $this->e(addslashes($af['file_name'])) ?>“ wird gelöscht.', 'Löschen')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="lvs-empty"><?= $this->te('Keine Lizenzdateien hochgeladen.') ?>
<?php if (!empty($file_info) && $can_manage): ?>
<br><small style="color:var(--gray-400);"><?= $this->t('Eine Datei ist im alten Format gespeichert — <a href="/licenses/{id}/file?download=1">herunterladen</a>', ['id' => (int)$e['id']]) ?></small>
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ($can_manage): ?>
<form action="/licenses/<?= (int)$e['id'] ?>/files" method="POST" enctype="multipart/form-data" style="margin-top:0.875rem;padding-top:0.875rem;border-top:1px solid var(--gray-100);">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<input type="file" name="license_files[]" multiple accept=".lic,.key,.xml,.txt,.pdf,.zip,.bin"
style="flex:1;min-width:0;font-size:0.8rem;padding:.25rem .5rem;border:1px solid var(--gray-300);border-radius:4px;background:var(--color-bg);">
<button type="submit" class="btn btn-sm btn-default">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<?= $this->te('Hochladen') ?>
</button>
</div>
</form>
<?php endif; ?>
</div>
</section>
<!-- Unterlizenzen -->
<?php if (!empty($children)): ?>
<section class="detail-card">
<div class="detail-card-header">
<h2 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
<?= $this->te('Unterlizenzen') ?>
<span style="background:var(--color-primary-soft);color:var(--color-primary-strong);border-radius:9999px;font-size:0.7rem;font-weight:600;padding:0.1rem 0.4rem;"><?= count($children) ?></span>
</h2>
</div>
<div class="detail-card-body">
<?php
$scCfg = ['active'=>['label'=>'Aktiv','cls'=>'status-pill--success'],'trial'=>['label'=>'Test','cls'=>'status-pill--info'],'expired'=>['label'=>'Abgelaufen','cls'=>'status-pill--danger'],'cancelled'=>['label'=>'Gekündigt','cls'=>'status-pill--neutral'],'archived'=>['label'=>'Archiviert','cls'=>'status-pill--neutral']];
?>
<div style="display:flex;flex-direction:column;gap:0.4rem;">
<?php foreach ($children as $ch):
$chSc = $scCfg[$ch['status']] ?? $scCfg['active'];
$chDays = !empty($ch['valid_until']) ? (int)ceil((strtotime($ch['valid_until'])-time())/86400) : null;
?>
<a href="/licenses/<?= (int)$ch['id'] ?>" class="lvs-contract-row">
<span style="flex-shrink:0;font-size:1rem;">🔑</span>
<div class="lvs-contract-info">
<span class="lvs-contract-name"><?= $this->e($ch['name']) ?></span>
<span class="lvs-contract-meta">
<span class="status-pill <?= $chSc['cls'] ?>" style="font-size:0.68rem;padding:0.05rem 0.4rem;"><?= $chSc['label'] ?></span>
<?php if (!empty($ch['vendor'])): ?> · <?= $this->e($ch['vendor']) ?><?php endif; ?>
<?php if ($chDays !== null): ?> · <?= $chDays < 0 ? 'abgelaufen' : 'bis '.date('d.m.Y', strtotime($ch['valid_until'])) ?><?php endif; ?>
<?php if ($ch['type'] === 'perpetual'): ?> <?= $this->te('· ∞ Dauerlizenz') ?><?php endif; ?>
</span>
</div>
<svg viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" width="14" height="14"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<?php endforeach; ?>
</div>
<?php if ($can_manage): ?>
<div style="margin-top:0.875rem;padding-top:0.875rem;border-top:1px solid var(--gray-100);">
<a href="/licenses/create?parent_id=<?= (int)$e['id'] ?>" class="btn btn-sm btn-default">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="13" height="13"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<?= $this->te('Unterlizenz hinzufügen') ?>
</a>
</div>
<?php endif; ?>
</div>
</section>
<?php elseif ($can_manage && empty($e['parent_id'])): ?>
<section class="detail-card">
<div class="detail-card-header">
<h2 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
<?= $this->te('Unterlizenzen') ?>
</h2>
</div>
<div class="detail-card-body">
<p class="lvs-empty"><?= $this->te('Noch keine Unterlizenzen.') ?></p>
<div style="margin-top:0.875rem;">
<a href="/licenses/create?parent_id=<?= (int)$e['id'] ?>" class="btn btn-sm btn-default">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="13" height="13"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<?= $this->te('Unterlizenz hinzufügen') ?>
</a>
</div>
</div>
</section>
<?php endif; ?>
<!-- Verknüpfte Verträge -->
<?php if ($contractsOn): ?>
<section class="detail-card">
<div class="detail-card-header">
<h2 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<?= $this->te('Verknüpfte Verträge') ?>
<?php if (!empty($e['contracts'])): ?>
<span style="background:var(--color-primary-soft);color:var(--color-primary-strong);border-radius:9999px;font-size:0.7rem;font-weight:600;padding:0.1rem 0.4rem;"><?= count($e['contracts']) ?></span>
<?php endif; ?>
</h2>
</div>
<div class="detail-card-body">
<?php if (empty($e['contracts'])): ?>
<p class="lvs-empty"><?= $this->te('Kein Vertrag verknüpft.') ?></p>
<?php else: ?>
<div style="display:flex;flex-direction:column;gap:0.5rem;">
<?php foreach ($e['contracts'] as $con): ?>
<a href="/contracts/<?= (int)$con['id'] ?>" class="lvs-contract-row">
<span style="font-size:1.1rem;flex-shrink:0;">📄</span>
<div class="lvs-contract-info">
<span class="lvs-contract-name"><?= $this->e($con['title']) ?></span>
<span class="lvs-contract-meta">
<?php if (!empty($con['company_name'])): ?>🏢 <?= $this->e($con['company_name']) ?><?php endif; ?>
<?php if (!empty($con['end_date'])): ?> · bis <?= date('d.m.Y', strtotime($con['end_date'])) ?><?php endif; ?>
</span>
</div>
<svg viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" width="14" height="14"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
</main>
<!-- ── SIDEBAR ──────────────────────────────────────── -->
<aside class="lvs-sidebar">
<!-- Übergeordnete Lizenz -->
<?php if ($parent): ?>
<section class="detail-card">
<div class="detail-card-header">
<h3 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<?= $this->te('Gruppe') ?>
</h3>
</div>
<div class="detail-card-body">
<a href="/licenses/<?= (int)$parent['id'] ?>" style="display:flex;align-items:center;gap:0.5rem;text-decoration:none;color:var(--blue-600);font-size:0.875rem;font-weight:500;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="15 18 9 12 15 6"/></svg>
<?= $this->e($parent['name']) ?>
</a>
<?php if (!empty($parent['vendor'])): ?>
<div style="font-size:0.75rem;color:var(--gray-400);margin-top:0.25rem;padding-left:1.35rem;"><?= $this->e($parent['vendor']) ?></div>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<!-- Status & Typ -->
<section class="detail-card">
<div class="detail-card-header">
<h3 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/></svg>
<?= $this->te('Status') ?>
</h3>
</div>
<div class="detail-card-body">
<dl class="lvs-dl">
<dt><?= $this->te('Status') ?></dt>
<dd><span class="status-pill <?= $stCfg['cls'] ?>"><?= $stCfg['label'] ?></span></dd>
<dt><?= $this->te('Typ') ?></dt>
<dd>
<span style="display:inline-block;padding:0.1rem 0.5rem;border-radius:3px;background:<?= $tc ?>18;color:<?= $tc ?>;font-size:0.78rem;font-weight:600;">
<?= $this->e($type_labels[$e['type']] ?? $e['type']) ?>
</span>
</dd>
</dl>
</div>
</section>
<!-- Lizenzdetails -->
<section class="detail-card">
<div class="detail-card-header">
<h3 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<?= $this->te('Lizenzdetails') ?>
</h3>
</div>
<div class="detail-card-body">
<dl class="lvs-dl">
<?php if (!empty($e['vendor'])): ?>
<dt><?= $this->te('Hersteller') ?></dt>
<dd><?= $this->e($e['vendor']) ?></dd>
<?php endif; ?>
<?php if (!empty($e['product'])): ?>
<dt><?= $this->te('Produkt') ?></dt>
<dd><?= $this->e($e['product']) ?></dd>
<?php endif; ?>
<?php if (!empty($e['valid_from'])): ?>
<dt><?= $this->te('Gültig ab') ?></dt>
<dd><?= date('d.m.Y', strtotime($e['valid_from'])) ?></dd>
<?php endif; ?>
<dt><?= $this->te('Gültig bis') ?></dt>
<dd>
<?php if (!empty($e['valid_until'])): ?>
<?= date('d.m.Y', strtotime($e['valid_until'])) ?>
<?php else: ?>
<span style="color:var(--gray-400);"><?= $this->te('Unbefristet') ?></span>
<?php endif; ?>
</dd>
<?php if ($seatsTot !== null || $seatsUsed > 0): ?>
<dt><?= $this->te('Seats') ?></dt>
<dd>
<?= $seatsUsed ?><?= $seatsTot !== null ? ' / ' . $seatsTot : '' ?>
<?php if ($seatsTot !== null): ?>
<div class="lvs-seat-track">
<div class="lvs-seat-fill" style="width:<?= $seatPct ?>%;background:<?= $seatPct >= 100 ? 'var(--red-500)' : ($seatPct >= 80 ? 'var(--amber-500)' : 'var(--blue-500)') ?>;"></div>
</div>
<?php endif; ?>
</dd>
<?php endif; ?>
<?php if (!empty($e['cost_amount'])): ?>
<dt><?= $this->te('Kosten') ?></dt>
<dd><?= $this->money((float)$e['cost_amount'], 2) ?><?= !empty($e['cost_interval']) ? ' / ' . ($cost_labels[$e['cost_interval']] ?? $e['cost_interval']) : '' ?></dd>
<?php endif; ?>
<dt><?= $this->te('Erstellt') ?></dt>
<dd><?= date('d.m.Y', strtotime($e['created_at'])) ?> von <?= $this->e($e['created_by_name'] ?? '–') ?></dd>
</dl>
</div>
</section>
<!-- Lizenzschlüssel -->
<?php if (!empty($e['license_key'])): ?>
<section class="detail-card">
<div class="detail-card-header">
<h3 class="detail-card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<?= $this->te('Lizenzschlüssel') ?>
</h3>
</div>
<div class="detail-card-body">
<div class="lvs-key-box">
<span class="lvs-key-masked" id="keyText"><?= $this->e($e['license_key']) ?></span>
</div>
<div class="lvs-key-actions">
<button type="button" class="lvs-key-btn" id="revealKeyBtn"><?= $this->te('👁 Anzeigen') ?></button>
<button type="button" class="lvs-key-btn" id="copyKeyBtn" data-key="<?= $this->e($e['license_key']) ?>"><?= $this->te('📋 Kopieren') ?></button>
</div>
</div>
</section>
<?php endif; ?>
</aside>
</div>
</div>
</div>
<script>
(function() {
var revealBtn = document.getElementById('revealKeyBtn');
var keyText = document.getElementById('keyText');
if (revealBtn && keyText) {
revealBtn.addEventListener('click', function() {
var masked = keyText.classList.contains('lvs-key-masked');
keyText.classList.toggle('lvs-key-masked', !masked);
revealBtn.innerHTML = masked ? '🙈 Verbergen' : '👁 Anzeigen';
});
}
var copyBtn = document.getElementById('copyKeyBtn');
if (copyBtn) {
copyBtn.addEventListener('click', function() {
if (navigator.clipboard) {
navigator.clipboard.writeText(copyBtn.getAttribute('data-key')).then(function() {
copyBtn.innerHTML = '✓ Kopiert!';
setTimeout(function() { copyBtn.innerHTML = '📋 Kopieren'; }, 2000);
});
}
});
}
})();
</script>
<?= $this->partial('confirm_modal') ?>
<script>
// Zuordnung einer Lizenz: Person und Gerät haben getrennte Suchwege. Die
// Personensuche bringt ihr eigenes Vorschlagsfeld mit; für Geräte genügt eine
// Liste, die beim Tippen nachgeladen wird.
(function () {
var typ = document.getElementById('lic-subject-type');
var ziel = document.getElementById('lic-subject-id');
if (!typ || !ziel) { return; }
var personWrap = document.getElementById('lic-person-wrap');
var assetWrap = document.getElementById('lic-asset-wrap');
var assetInput = document.getElementById('lic-asset-search');
var assetList = document.getElementById('lic-asset-list');
var assetHint = document.getElementById('lic-asset-hint');
var gefunden = {}; // Beschriftung -> Kennung
function umschalten() {
var istGeraet = typ.value === 'asset';
personWrap.style.display = istGeraet ? 'none' : '';
assetWrap.style.display = istGeraet ? '' : 'none';
// Bei einem Wechsel darf keine Kennung des anderen Typs stehen bleiben.
ziel.value = '';
assetInput.value = '';
document.getElementById('lic-person-search').value = '';
}
typ.addEventListener('change', umschalten);
var wartet;
assetInput.addEventListener('input', function () {
ziel.value = gefunden[assetInput.value] || '';
if (ziel.value !== '') { assetHint.textContent = 'Gerät gewählt.'; return; }
assetHint.textContent = 'Mindestens zwei Zeichen eingeben, dann aus der Liste wählen.';
clearTimeout(wartet);
if (assetInput.value.trim().length < 2) { return; }
wartet = setTimeout(function () {
fetch('/api/licenses/assets?q=' + encodeURIComponent(assetInput.value.trim()), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
})
.then(function (r) { return r.ok ? r.json() : { results: [] }; })
.then(function (d) {
assetList.innerHTML = '';
gefunden = {};
(d.results || []).forEach(function (a) {
var text = (a.name || a.inventory_number || ('#' + a.id));
if (a.inventory_number && a.name) { text += ' (' + a.inventory_number + ')'; }
gefunden[text] = a.id;
var o = document.createElement('option');
o.value = text;
assetList.appendChild(o);
});
if ((d.results || []).length === 0) { assetHint.textContent = 'Kein Gerät gefunden.'; }
})
.catch(function () { assetHint.textContent = 'Die Suche ist gerade nicht erreichbar.'; });
}, 250);
});
})();
</script>