Quelltext des aktuellen Standes
<?php $this->layout('layouts/admin', [
'page_title' => 'Unbekannte Geräte',
'current_admin_page' => 'assets-unmatched',
'breadcrumbs' => [['label' => 'Unbekannte Geräte']],
]); ?>
<!-- Übersicht -->
<div class="card card-outline card-primary mb-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-question-circle"></i> <?= $this->t('Unbekannte Geräte') ?> (<?= count($devices) ?>)</h3>
<p class="text-muted small mb-0"><?= $this->te('Geräte, die vom PowerShell-Script erkannt, aber nicht zu Assets zugeordnet wurden.') ?></p>
</div>
<div class="card-body table-responsive p-0">
<?php if (empty($devices)): ?>
<div style="padding:2rem;text-align:center;" class="text-muted">
<i class="fas fa-check-circle text-success"></i> <?= $this->te('Alle Geräte wurden zugeordnet.') ?>
</div>
<?php else: ?>
<div class="admin-table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:0.875rem;min-width:400px;">
<thead>
<tr>
<th scope="col" style="padding:0.65rem 1rem;text-align:left;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--gray-500);background:var(--gray-50);border-bottom:2px solid var(--gray-200);"><?= $this->te('Hostname') ?></th>
<th scope="col" style="padding:0.65rem 1rem;text-align:left;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--gray-500);background:var(--gray-50);border-bottom:2px solid var(--gray-200);"><?= $this->te('Quelle') ?></th>
<th scope="col" style="padding:0.65rem 1rem;text-align:left;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--gray-500);background:var(--gray-50);border-bottom:2px solid var(--gray-200);"><?= $this->te('Serial Number') ?></th>
<th scope="col" style="padding:0.65rem 1rem;text-align:left;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--gray-500);background:var(--gray-50);border-bottom:2px solid var(--gray-200);"><?= $this->te('Letzter Benutzer') ?></th>
<th scope="col" style="padding:0.65rem 1rem;text-align:left;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--gray-500);background:var(--gray-50);border-bottom:2px solid var(--gray-200);"><?= $this->te('Hardware') ?></th>
<th scope="col" style="padding:0.65rem 1rem;text-align:left;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--gray-500);background:var(--gray-50);border-bottom:2px solid var(--gray-200);"><?= $this->te('Erstmals gesehen') ?></th>
<th scope="col" style="padding:0.65rem 1rem;text-align:center;font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--gray-500);background:var(--gray-50);border-bottom:2px solid var(--gray-200);"><?= $this->te('Aktion') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($devices as $device): ?>
<tr style="border-bottom:1px solid var(--gray-200);">
<td style="padding:0.75rem 1rem;"><strong><?= htmlspecialchars($device['hostname']) ?></strong></td>
<td style="padding:0.75rem 1rem;">
<?php $src = $device['source'] ?? 'script'; ?>
<?php if ($src === 'intune'): ?>
<span style="display:inline-block;padding:0.15rem 0.5rem;border-radius:9999px;font-size:0.7rem;font-weight:700;background:var(--indigo-100);color:var(--indigo-800);"><?= $this->te('Intune') ?></span>
<?php else: ?>
<span style="display:inline-block;padding:0.15rem 0.5rem;border-radius:9999px;font-size:0.7rem;font-weight:700;background:var(--gray-100);color:var(--gray-500);"><?= $this->te('Script') ?></span>
<?php endif; ?>
</td>
<td style="padding:0.75rem 1rem;">
<?php if ($device['serial_number']): ?>
<code style="background:var(--gray-100);padding:0.2rem 0.4rem;border-radius:0.25rem;"><?= htmlspecialchars($device['serial_number']) ?></code>
<?php else: ?>
<span style="color:var(--gray-400);">—</span>
<?php endif; ?>
</td>
<td style="padding:0.75rem 1rem;">
<?= $device['last_username'] ? htmlspecialchars($device['last_username']) : '—' ?>
</td>
<td style="padding:0.75rem 1rem;font-size:0.85rem;color:var(--gray-500);">
<?php
$specs = $device['hardware_specs'];
$parts = [];
// Die Angaben stammen vom Sync-Agenten und sind reine Fremddaten.
if (!empty($specs['cpu_model'])) $parts[] = mb_substr((string)$specs['cpu_model'], 0, 30);
if (!empty($specs['ram_gb'])) $parts[] = mb_substr((string)$specs['ram_gb'], 0, 20) . 'GB';
if (!empty($specs['os_name'])) $parts[] = mb_substr((string)$specs['os_name'], 0, 60);
echo $parts ? htmlspecialchars(implode(', ', $parts)) : '—';
?>
</td>
<td style="padding:0.75rem 1rem;font-size:0.85rem;color:var(--gray-500);">
<?= date('d.m.Y H:i', strtotime($device['first_seen_at'])) ?>
</td>
<td style="padding:0.75rem 1rem;text-align:center;">
<button type="button" class="btn btn-sm btn-primary" onclick="openAssignModal(<?= $device['id'] ?>, '<?= htmlspecialchars(addslashes($device['hostname'])) ?>', <?= (int)($device['suggested_category_id'] ?? 0) ?>)">
<?= $this->te('Zuordnen') ?>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Zuordnungs-Modal -->
<div id="assignModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center;">
<div style="background:white;border-radius:0.5rem;width:90%;max-width:600px;max-height:90vh;overflow-y:auto;box-shadow:0 20px 25px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="padding:1.5rem;border-bottom:1px solid var(--gray-200);display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:1.125rem;"><?= $this->te('Gerät zuordnen:') ?> <code id="modalHostname"></code></h3>
<button type="button" onclick="closeAssignModal()" class="modal-close" aria-label="<?= $this->te('Schließen') ?>">✕</button>
</div>
<!-- Content -->
<div style="padding:1.5rem;">
<form id="assignForm" method="POST">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="device_id" id="modalDeviceId">
<input type="hidden" name="action" id="modalAction">
<div style="margin-bottom:1.5rem;">
<label style="display:block;font-weight:600;margin-bottom:0.75rem;">
<input type="radio" name="assign_type" value="existing" checked onchange="switchTab('existing')">
<?= $this->te('Zu existierendem Asset zuordnen') ?>
</label>
<label style="display:block;font-weight:600;">
<input type="radio" name="assign_type" value="new" onchange="switchTab('new')">
<?= $this->te('Neues Asset erstellen') ?>
</label>
</div>
<!-- Existing Asset Tab -->
<div id="existing-content" style="display:block;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-weight:600;margin-bottom:0.5rem;"><?= $this->te('Asset suchen:') ?></label>
<input type="text" id="assetSearch" class="form-control" placeholder="<?= $this->te('Name, Inventarnummer, Serial...') ?>" autocomplete="off" style="width:100%;padding:0.5rem;border:1px solid var(--gray-300);border-radius:0.375rem;">
<span class="form-hint"><?= $this->te('Mindestens zwei Zeichen eingeben.') ?></span>
<div id="assetSearchResults" style="margin-top:0.75rem;"></div>
</div>
</div>
<!-- New Asset Tab -->
<div id="new-content" style="display:none;">
<div style="margin-bottom:1rem;">
<label for="categorySelect" style="display:block;font-weight:600;margin-bottom:0.5rem;"><?= $this->te('Kategorie:') ?> <span style="color:var(--red-600);">*</span></label>
<select name="category_id" id="categorySelect" class="form-control" required style="width:100%;padding:0.5rem;border:1px solid var(--gray-300);border-radius:0.375rem;">
<option value=""><?= $this->te('— Wählen —') ?></option>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>"><?= htmlspecialchars($cat['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="margin-bottom:1rem;">
<label for="manufacturerSelect" style="display:block;font-weight:600;margin-bottom:0.5rem;"><?= $this->te('Hersteller:') ?></label>
<select name="manufacturer_id" id="manufacturerSelect" class="form-control" style="width:100%;padding:0.5rem;border:1px solid var(--gray-300);border-radius:0.375rem;">
<option value=""><?= $this->te('— Optional —') ?></option>
<?php foreach ($manufacturers as $mfg): ?>
<option value="<?= $mfg['id'] ?>"><?= htmlspecialchars($mfg['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="margin-bottom:1rem;">
<label for="typeSelect" style="display:block;font-weight:600;margin-bottom:0.5rem;"><?= $this->te('Modell/Typ:') ?></label>
<select name="type_id" id="typeSelect" class="form-control" style="width:100%;padding:0.5rem;border:1px solid var(--gray-300);border-radius:0.375rem;">
<option value=""><?= $this->te('— Optional —') ?></option>
<?php foreach ($types as $t): ?>
<option value="<?= (int)$t['id'] ?>"
data-manufacturer="<?= (int)$t['manufacturer_id'] ?>"
data-category="<?= $t['category_id'] ? (int)$t['category_id'] : '' ?>">
<?= htmlspecialchars($t['manufacturer_name'] . ' – ' . $t['name']) ?>
</option>
<?php endforeach; ?>
</select>
<span class="form-hint"><?= $this->te('Wird nach Hersteller & Kategorie gefiltert.') ?></span>
</div>
</div>
<!-- Buttons -->
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;justify-content:flex-end;">
<button type="button" class="btn btn-default" onclick="closeAssignModal()"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary"><?= $this->te('Zuordnen') ?></button>
</div>
</form>
</div>
</div>
</div>
<script>
// Modell-/Typ-Auswahl zeigt nur Einträge, die zum gewählten Hersteller und
// zur Kategorie passen (gleiche Logik wie im Inventar-Formular).
function filterUnmatchedTypes() {
var mfrId = document.getElementById('manufacturerSelect').value;
var catId = document.getElementById('categorySelect').value;
var sel = document.getElementById('typeSelect');
Array.from(sel.options).forEach(function(opt) {
if (!opt.value) { opt.hidden = false; opt.disabled = false; return; }
var mfrMatch = !mfrId || opt.dataset.manufacturer === mfrId;
var catMatch = !catId || !opt.dataset.category || opt.dataset.category === catId;
var ok = mfrMatch && catMatch;
opt.hidden = !ok;
opt.disabled = !ok;
if (!ok && sel.value === opt.value) sel.value = '';
});
}
document.getElementById('manufacturerSelect').addEventListener('change', filterUnmatchedTypes);
document.getElementById('categorySelect').addEventListener('change', filterUnmatchedTypes);
// ── Asset-Suche für die Zuordnung ───────────────────────────────────
// Die Treffer werden als Radioliste gerendert; ausgewählt wird über das Feld
// asset_id, das der Absende-Handler erwartet.
var assetSearchTimer = null;
function escapeHtml(wert) {
return String(wert == null ? '' : wert).replace(/[&<>"']/g, function (z) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[z];
});
}
function renderAssetResults(treffer) {
var box = document.getElementById('assetSearchResults');
if (!treffer.length) {
box.innerHTML = '<div class="text-muted" style="font-size:0.85rem;">Keine passenden Assets gefunden.</div>';
return;
}
var html = '';
treffer.forEach(function (a) {
var zeile2 = [a.model, a.serial_number, a.location, a.assigned_to]
.filter(function (t) { return t; })
.map(escapeHtml)
.join(' · ');
html += '<label style="display:block;padding:0.5rem;border:1px solid var(--gray-200);border-radius:0.375rem;margin-bottom:0.4rem;cursor:pointer;">'
+ '<input type="radio" name="asset_id" value="' + a.id + '" style="margin-right:0.5rem;">'
+ '<strong>' + escapeHtml(a.inventory_number || a.name) + '</strong>'
+ (a.inventory_number && a.name ? ' – ' + escapeHtml(a.name) : '')
+ (zeile2 ? '<div style="margin-left:1.35rem;font-size:0.8rem;color:var(--gray-500);">' + zeile2 + '</div>' : '')
+ '</label>';
});
box.innerHTML = html;
}
document.getElementById('assetSearch').addEventListener('input', function () {
var begriff = this.value.trim();
var box = document.getElementById('assetSearchResults');
clearTimeout(assetSearchTimer);
if (begriff.length < 2) {
box.innerHTML = '';
return;
}
assetSearchTimer = setTimeout(function () {
box.innerHTML = '<div class="text-muted" style="font-size:0.85rem;">Suche …</div>';
fetch('/admin/inventory/unmatched-devices/asset-search?q=' + encodeURIComponent(begriff), {
headers: { 'Accept': 'application/json' }
})
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (d) { renderAssetResults(d.results || []); })
.catch(function () {
box.innerHTML = '<div class="text-danger" style="font-size:0.85rem;">Suche fehlgeschlagen.</div>';
});
}, 250);
});
function openAssignModal(deviceId, hostname, suggestedCategoryId) {
document.getElementById('modalDeviceId').value = deviceId;
document.getElementById('modalHostname').textContent = hostname;
document.getElementById('assignModal').style.display = 'flex';
document.getElementById('assignForm').reset();
document.getElementById('assetSearchResults').innerHTML = '';
switchTab('existing');
// Kategorie-Vorschlag (z. B. aus Intune-Geräteklasse) vorauswählen
if (suggestedCategoryId) {
var sel = document.getElementById('categorySelect');
if (sel) sel.value = String(suggestedCategoryId);
}
filterUnmatchedTypes();
}
function closeAssignModal() {
document.getElementById('assignModal').style.display = 'none';
}
function switchTab(tab) {
const existingContent = document.getElementById('existing-content');
const newContent = document.getElementById('new-content');
if (tab === 'existing') {
existingContent.style.display = 'block';
newContent.style.display = 'none';
} else {
existingContent.style.display = 'none';
newContent.style.display = 'block';
}
}
document.getElementById('assignForm').addEventListener('submit', async (e) => {
e.preventDefault();
const assignType = document.querySelector('input[name="assign_type"]:checked').value;
const deviceId = document.getElementById('modalDeviceId').value;
const form = new FormData(document.getElementById('assignForm'));
if (assignType === 'existing') {
const selectedAsset = document.querySelector('input[name="asset_id"]:checked');
if (!selectedAsset) {
alert('Bitte wähle ein Asset aus.');
return;
}
form.set('action', 'assign_existing');
} else {
const categoryId = document.getElementById('categorySelect').value;
if (!categoryId) {
alert('Bitte wähle eine Kategorie aus.');
return;
}
form.set('action', 'create_new');
}
let data;
try {
const response = await fetch(`/admin/inventory/unmatched-devices/${deviceId}/assign`, {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: form
});
data = await response.json();
} catch (e) {
alert('Netzwerkfehler: ' + e.message);
return;
}
if (data.success) {
const msg = data.inventory_number
? `Gerät als Asset angelegt (${data.inventory_number}).`
: 'Gerät erfolgreich zugeordnet!';
alert(msg);
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
});
</script>