Quelltext des aktuellen Standes
<?php
$_dashCss = ['/assets/css/dashboard.css?v=' . filemtime(dirname(__DIR__, 2) . '/public/assets/css/dashboard.css')];
// Das Notizbrett-Widget nutzt die Stile seines Moduls; ohne sie steht es
// unformatiert auf der Seite.
if (($modules['teamboard'] ?? false) && in_array('teamboard', $all_widgets ?? [], true)) {
$_dashCss[] = '/assets/css/teamboard.css';
}
$this->layout('layouts/main', [
'page_title' => 'Dashboard',
'full_width' => true,
'extra_css' => $_dashCss,
]);
$_useFA = ($icon_style ?? 'fontawesome') === 'fontawesome';
// Zentrale Icon-Map (eine Quelle für PHP-Helper und globalen JS-Umschalter).
$_iconMap = \OpenNIT\Core\IconMap::MAP;
$icon = function (string $fa, string $emoji = '') use ($_useFA, $_iconMap): string {
if ($_useFA) {
return '<i class="fa-solid ' . $fa . '"></i>';
}
if ($emoji !== '') {
return $emoji;
}
// Nicht gemappt → Font-Awesome-Icon beibehalten (kein „•"-Platzhalter).
return $_iconMap[$fa] ?? ('<i class="fa-solid ' . $fa . '"></i>');
};
$pinnedServices = $pinned_services ?? [];
$hasViewServices = $has_view_services ?? false;
$openOrders = $open_orders ?? [];
$deliveredOrders = $delivered_orders ?? [];
$abholbereitOrders = $abholbereit_orders ?? [];
$abholbereitEnabled = $abholbereit_enabled ?? false;
$orderCounts = $order_counts ?? ['open' => 0, 'delivered' => 0, 'abholbereit' => 0];
$hasManageOrders = $has_manage_orders ?? false;
$allOrders = array_merge($openOrders, $deliveredOrders, $abholbereitOrders);
$loanDevices = $loan_devices ?? [];
$loanSummary = $loan_summary ?? [];
$hasViewLoans = $has_view_loans ?? false;
$widgetsByColumn = $widgets_by_column ?? [1 => [], 2 => [], 3 => []];
$hiddenWidgets = $hidden_widgets ?? [];
$allWidgets = $all_widgets ?? ['pinned_services','open_orders','pending_reviews','quick_actions','my_todos','loan_devices','my_tickets','my_kanban_tasks','monitoring','maintenance_upcoming','phonebook_search','cve_security','contracts_expiring','workflow_approvals','teamboard'];
// Rückwärtskompatibilität: Spalten-Map für das Konfig-Panel aufbauen
$widgetColumnMap = [];
foreach ([1,2,3] as $_col) {
foreach ($widgetsByColumn[$_col] as $_w) { $widgetColumnMap[$_w] = $_col; }
}
$teamboardNotes = $teamboard_notes ?? [];
$myTodos = $my_todos ?? [];
$myTodosDoneCount = $my_todos_done_count ?? 0;
$myKanbanTasks = $my_kanban_tasks ?? [];
$hasViewKanban = $has_view_kanban ?? false;
$monitoringEvents = $monitoring_events ?? [];
$monitoringCounts = $monitoring_counts ?? ['active' => 0, 'critical' => 0];
$hasViewMonitoring= $has_view_monitoring?? false;
$backupCounts = $backup_counts ?? ['total' => 0, 'failed' => 0, 'warning' => 0];
$backupJobs = $backup_jobs ?? [];
$hasViewBackup = $has_view_backup ?? false;
$contractsExpiring = $contracts_expiring ?? [];
$maintenanceUpcoming = $maintenance_upcoming ?? [];
$maintenanceUrgentCount = $maintenance_urgent_count ?? 0;
$hasViewMaintenance = $has_view_maintenance ?? false;
$maintenanceSources = $maintenance_sources ?? ['manual', 'ssl', 'contract'];
$dashboardColumns = max(1, min(3, (int)($dashboard_columns ?? 2)));
$iconBasePath = '/uploads/service-icons/';
$availableQuickLinks = $available_quick_links ?? [];
$activeQuickLinks = $active_quick_links ?? [];
$activeQuickLinkKeys = $active_quick_link_keys ?? array_keys($availableQuickLinks);
$quickAccessCols = max(2, min(3, (int)($quick_access_cols ?? 2)));
$unifiVoucherSites = $unifi_voucher_sites ?? [];
$hasManageUnifiVouchers = $has_manage_unifi_vouchers ?? false;
$dashModules = $modules ?? [];
$unifiModuleEnabled = (bool)($dashModules['unifi'] ?? false);
// Widget-Labels für Konfig-Panel
$widgetLabels = [
'pinned_services' => [$icon('fa-thumbtack', '📌') . ' ' . $this->t('Meine Pins'), $this->t('Angepinnte Services')],
'open_orders' => [$icon('fa-shopping-cart', '🛒') . ' ' . $this->t('Bestellungen'), $this->t('Offene Bestellungen')],
'pending_reviews' => [$icon('fa-exclamation-triangle', '⚠️') . ' ' . $this->t('Anstehende Reviews'), $this->t('Fällige Seitenreviews')],
'quick_actions' => [$icon('fa-bolt', '🚀') . ' ' . $this->t('Schnellzugriff'), $this->t('Schnellzugriff-Grid')],
'loan_devices' => [$icon('fa-laptop', '💻') . ' ' . $this->t('Leihgeräte'), $this->t('Gerätestatus-Übersicht')],
'my_tickets' => [$icon('fa-ticket-alt', '🎫') . ' ' . $this->t('Meine Tickets'), $this->t('Neue & mir zugewiesene Tickets')],
'my_kanban_tasks' => [$icon('fa-clipboard-list', '📋') . ' ' . $this->t('Meine Aufgaben'), $this->t('Mir zugewiesene Kanban-Aufgaben')],
'workflow_approvals' => [$icon('fa-clipboard-check', '✅') . ' ' . $this->t('Genehmigungen'), $this->t('Offene Anträge, die auf mich warten')],
'my_todos' => [$icon('fa-check-square', '✅') . ' ' . $this->t('To-Do-Liste'), $this->t('Persönliche Aufgabenliste')],
'teamboard' => [$icon('fa-sticky-note', '🗒️') . ' ' . $this->t('Notizbrett'), $this->t('Die letzten Zurufe im Team')],
'monitoring' => [$icon('fa-chart-bar', '📊') . ' ' . $this->t('Monitoring'), $this->t('Zabbix-Probleme & Alarme')],
'backup' => [$icon('fa-database', '💾') . ' ' . $this->t('Backup'), $this->t('Veeam & Proxmox Status')],
'maintenance_upcoming' => [$icon('fa-calendar', '📅') . ' ' . $this->t('Wartungstermine'), $this->t('Nächste Fälligkeiten (60 Tage)')],
'phonebook_search' => [$icon('fa-phone', '📞') . ' ' . $this->t('Telefonbuch'), $this->t('Personen schnell suchen')],
'cve_security' => [$icon('fa-shield-alt', '🛡') . ' ' . $this->t('CVE-Meldungen'), $this->t('Aktuelle Sicherheitslücken')],
'unifi_vouchers' => [$icon('fa-wifi', '📶') . ' ' . $this->t('UniFi Voucher'), $this->t('Gast-Voucher direkt erstellen')],
'contracts_expiring' => [$icon('fa-file-contract', '📄') . ' ' . $this->t('Kündigungsfristen'), $this->t('Verträge mit Frist in 60 Tagen')],
];
?>
<div class="dashboard-fullwidth">
<!-- Content Header -->
<?php
$displayName = $current_user->display_name ?? 'Benutzer';
$firstName = $displayName;
if (str_contains($displayName, ',')) {
$parts = explode(',', $displayName, 2);
$firstName = trim($parts[1] ?? $displayName);
} elseif (str_contains($displayName, ' ')) {
$parts = explode(' ', $displayName, 2);
$firstName = trim($parts[0]);
}
?>
<div class="content-header">
<div>
<h1><?= $this->te('Willkommen') ?>, <?= $this->e($firstName) ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item active"><?= $this->te('Dashboard') ?></li>
</ol>
</div>
<div class="content-header-actions">
<a href="/topics" class="btn btn-default btn-sm"><?= $this->te('Themengebiete') ?></a>
<button class="btn btn-default btn-sm" onclick="openWidgetConfig()" title="<?= $this->te('Dashboard anpassen') ?>">
<?= $icon('fa-cog', '⚙️') ?> <?= $this->te('Anpassen') ?>
</button>
</div>
</div>
<div class="dashboard-body">
<!-- Widget-Grid (3 explizite Spalten, per Drag & Drop verschiebbar) -->
<div class="dashboard-grid" id="widgetGrid" style="grid-template-columns:repeat(<?= $dashboardColumns ?>,1fr)">
<?php foreach ([1, 2, 3] as $_colNum): ?>
<div class="dashboard-col" id="dashCol<?= $_colNum ?>" data-col="<?= $_colNum ?>">
<?php foreach ($widgetsByColumn[$_colNum] as $widgetId): ?>
<?php switch($widgetId):
case 'pinned_services': ?>
<?php if ($hasViewServices && !empty($pinnedServices)): ?>
<section class="dashboard-card" data-widget="pinned_services">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-thumbtack', '📌') ?> <?= $this->te('Meine Pins') ?></h2>
</div>
<a href="/services" class="card-link"><?= $this->te('Alle Services →') ?></a>
</div>
<div class="card-body">
<div class="pin-grid" id="pinGrid">
<?php foreach ($pinnedServices as $s): ?>
<a href="<?= $this->e($s['url']) ?>" target="_blank" rel="noopener"
class="pin-tile" draggable="true" data-service-id="<?= $s['id'] ?>">
<div class="pin-drag-handle" title="<?= $this->te('Ziehen zum Sortieren') ?>">⋮⋮</div>
<div class="pin-icon-wrap">
<?php if (!empty($s['icon'])): ?>
<img src="<?= $iconBasePath . $this->e($s['icon']) ?>" alt="" class="pin-icon-img">
<?php else: ?>
<span class="pin-icon-letter" <?php if (!empty($s['category_color'])): ?>style="background:<?= $this->e($s['category_color']) ?>"<?php endif; ?>>
<?= strtoupper(mb_substr($s['name'], 0, 1)) ?>
</span>
<?php endif; ?>
</div>
<div class="pin-name"><?= $this->e($s['name']) ?></div>
<?php if ($s['reachability']): ?>
<span class="pin-reach"><?= $s['reachability'] === 'internal' ? $icon('fa-building', '🏢') : $icon('fa-globe', '🌐') ?></span>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</div>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'open_orders': ?>
<?php if ($hasManageOrders && !empty($allOrders)): ?>
<section class="dashboard-card" data-widget="open_orders">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-shopping-cart', '🛒') ?> <?= $this->te('Bestellungen') ?></h2>
</div>
<a href="/orders" class="card-link"><?= $this->te('Bestell-Board →') ?></a>
</div>
<div class="card-body">
<?php if (!empty($openOrders)): ?>
<div class="order-group-label"><?= $this->te('Offen') ?> (<?= count($openOrders) ?>)</div>
<div class="dash-order-list">
<?php foreach (array_slice($openOrders, 0, 5) as $o):
$total = (int)($o['comp_total'] ?? 0);
$done = (int)($o['comp_done'] ?? 0);
$pct = ($total > 0) ? (int)floor($done * 100 / $total) : 0;
?>
<a href="/orders?tab=open#order-<?= $o['id'] ?>" class="dash-order-item">
<span class="dash-order-dot open"></span>
<span class="dash-order-title"><?= $this->e($o['title']) ?></span>
<span class="dash-order-progress">
<span class="dash-progress-bar"><span class="dash-progress-fill" style="width:<?= $pct ?>%"></span></span>
<span class="dash-progress-text"><?= $done ?>/<?= $total ?></span>
</span>
</a>
<?php endforeach; ?>
<?php if (count($openOrders) > 5): ?>
<div class="dash-order-more"><?= $this->te('+ {anzahl} weitere', ['anzahl' => count($openOrders) - 5]) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($deliveredOrders)): ?>
<div class="order-group-label" <?= !empty($openOrders) ? 'style="margin-top:1rem;"' : '' ?>><?= $this->te('Eingetroffen') ?> (<?= count($deliveredOrders) ?>)</div>
<div class="dash-order-list">
<?php foreach (array_slice($deliveredOrders, 0, 5) as $o): ?>
<a href="/orders?tab=delivered#order-<?= $o['id'] ?>" class="dash-order-item delivered">
<span class="dash-order-dot delivered"></span>
<span class="dash-order-title"><?= $this->e($o['title']) ?></span>
<span class="dash-order-badge"><?= $icon('fa-check', '✓') ?> <?= $this->te('Bereit') ?></span>
</a>
<?php endforeach; ?>
<?php if (count($deliveredOrders) > 5): ?>
<div class="dash-order-more"><?= $this->te('+ {anzahl} weitere', ['anzahl' => count($deliveredOrders) - 5]) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($abholbereitEnabled && !empty($abholbereitOrders)): ?>
<div class="order-group-label" style="margin-top:1rem;"><?= $this->te('Abholbereit') ?> (<?= count($abholbereitOrders) ?>)</div>
<div class="dash-order-list">
<?php foreach (array_slice($abholbereitOrders, 0, 5) as $o): ?>
<a href="/orders?tab=abholbereit#order-<?= $o['id'] ?>" class="dash-order-item abholbereit">
<span class="dash-order-dot abholbereit"></span>
<span class="dash-order-title"><?= $this->e($o['title']) ?></span>
<span class="dash-order-badge" style="color:var(--violet-500);"><?= $icon('fa-box-open', '📦') ?> <?= $this->te('Abholbereit') ?></span>
</a>
<?php endforeach; ?>
<?php if (count($abholbereitOrders) > 5): ?>
<div class="dash-order-more"><?= $this->te('+ {anzahl} weitere', ['anzahl' => count($abholbereitOrders) - 5]) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'pending_reviews': ?>
<?php if (!empty($pending_reviews)): ?>
<section class="dashboard-card attention" data-widget="pending_reviews">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-exclamation-triangle', '⚠️') ?> <?= $this->te('Anstehende Reviews') ?></h2>
</div>
<a href="/my-responsibilities" class="card-link"><?= $this->te('Alle →') ?></a>
</div>
<div class="card-body">
<div class="review-list">
<?php foreach ($pending_reviews as $review): ?>
<?php $isOverdue = strtotime($review['next_review_date']) < time(); ?>
<a href="/pages/<?= $review['id'] ?>/responsibilities" class="review-item <?= $isOverdue ? 'overdue' : '' ?>">
<div class="review-content">
<div class="review-title"><?= $this->e($review['title']) ?></div>
<div class="review-date">
<?php if ($isOverdue): ?>
<span class="text-danger"><?= $this->te('Überfällig seit') ?> <?= date('d.m.Y', strtotime($review['next_review_date'])) ?></span>
<?php else: ?>
<?= $this->te('Fällig am') ?> <?= date('d.m.Y', strtotime($review['next_review_date'])) ?>
<?php endif; ?>
</div>
</div>
<span class="review-badge <?= $isOverdue ? 'badge-danger' : 'badge-warning' ?>">
<?= $isOverdue ? '!' : '?' ?>
</span>
</a>
<?php endforeach; ?>
</div>
</div>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'quick_actions': ?>
<section class="dashboard-card" data-widget="quick_actions">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-bolt', '🚀') ?> <?= $this->te('Schnellzugriff') ?></h2>
</div>
<button onclick="openQAConfig()" class="card-link" style="background:none;border:none;cursor:pointer;" title="<?= $this->te('Schnellzugriff anpassen') ?>"><?= $icon('fa-cog', '⚙️') ?> <?= $this->te('Anpassen') ?></button>
</div>
<div class="card-body">
<div class="quick-actions-grid" id="qaGrid" style="grid-template-columns:repeat(<?= $quickAccessCols ?>,1fr)">
<?php foreach ($activeQuickLinks as $ql): ?>
<a href="<?= $this->e($ql['url']) ?>" class="quick-action-card" data-qakey="<?= $this->e($ql['key']) ?>">
<span class="qa-icon"><?= $icon($ql['icon']) ?></span>
<span class="qa-label"><?= $this->te($ql['label']) ?></span>
<span class="qa-desc"><?= $this->te($ql['desc']) ?></span>
</a>
<?php endforeach; ?>
<?php if (empty($activeQuickLinks)): ?>
<div style="grid-column:1/-1;text-align:center;padding:1.5rem;color:var(--gray-400);font-size:0.82rem;">
<?= $this->te('Keine Einträge ausgewählt.') ?><br>
<button onclick="openQAConfig()" style="margin-top:0.5rem;background:none;border:none;color:var(--color-primary);cursor:pointer;font-size:0.82rem;text-decoration:underline;"><?= $this->te('Jetzt einrichten') ?></button>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php break; ?>
<?php case 'loan_devices': ?>
<?php if ($hasViewLoans): ?>
<section class="dashboard-card" data-widget="loan_devices">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-laptop', '💻') ?> <?= $this->te('Leihgeräte') ?></h2>
</div>
<a href="/loans" class="card-link"><?= $this->te('Kalender →') ?></a>
</div>
<div class="card-body">
<?php if (!empty($loanSummary)): ?>
<div class="loan-kpi-row">
<span class="loan-kpi" style="--kc:var(--gray-500);">
<span class="loan-kpi-val"><?= (int)($loanSummary['total'] ?? 0) ?></span>
<span class="loan-kpi-lbl"><?= $this->te('Gesamt') ?></span>
</span>
<span class="loan-kpi" style="--kc:var(--green-600);">
<span class="loan-kpi-val"><?= (int)($loanSummary['available'] ?? 0) ?></span>
<span class="loan-kpi-lbl"><?= $this->te('Verfügbar') ?></span>
</span>
<span class="loan-kpi" style="--kc:var(--red-600);">
<span class="loan-kpi-val"><?= (int)($loanSummary['lent'] ?? 0) ?></span>
<span class="loan-kpi-lbl"><?= $this->te('Ausgeliehen') ?></span>
</span>
<span class="loan-kpi" style="--kc:var(--amber-600);">
<span class="loan-kpi-val"><?= (int)($loanSummary['reserved'] ?? 0) ?></span>
<span class="loan-kpi-lbl"><?= $this->te('Reserviert') ?></span>
</span>
<?php if (($loanSummary['overdue'] ?? 0) > 0): ?>
<span class="loan-kpi" style="--kc:var(--red-600);">
<span class="loan-kpi-val"><?= $icon('fa-exclamation-triangle', '⚠️') ?> <?= (int)$loanSummary['overdue'] ?></span>
<span class="loan-kpi-lbl"><?= $this->te('Überfällig') ?></span>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (empty($loanDevices)): ?>
<p style="font-size:0.85rem;color:var(--gray-400);text-align:center;padding:1rem 0;"><?= $this->te('Keine Leihgeräte vorhanden.') ?></p>
<?php else: ?>
<div class="loan-device-list">
<?php foreach ($loanDevices as $dev):
$status = $dev['status'];
$statusCfg = [
'available' => ['Verfügbar', '#16a34a', '#dcfce7'],
'lent' => ['Ausgeliehen', '#dc2626', '#fee2e2'],
'reserved' => ['Reserviert', '#d97706', '#fef3c7'],
'maintenance' => ['Wartung', '#7c3aed', '#ede9fe'],
];
[$slabel, $scol, $sbg] = $statusCfg[$status] ?? ['Unbekannt','#6b7280','#f3f4f6'];
$activeLend = $dev['active_lending'] ?? null;
$nextRes = $dev['next_reservation'] ?? null;
$isOverdue = $activeLend && $activeLend['planned_end'] < date('Y-m-d');
if ($isOverdue) { $slabel = 'Überfällig'; $scol = '#fff'; $sbg = '#dc2626'; }
?>
<a href="/loans/devices/<?= $dev['id'] ?>" class="loan-device-row">
<div class="loan-device-icon">
<?php if (!empty($dev['image_path'])): ?>
<img src="<?= $this->e($dev['image_path']) ?>" alt="">
<?php else: ?>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<?php endif; ?>
</div>
<div class="loan-device-info">
<div class="loan-device-name"><?= $this->e($dev['title']) ?></div>
<?php if ($activeLend): ?>
<div class="loan-device-sub">
<span style="color:<?= $isOverdue ? 'var(--red-600)' : 'var(--red-600)' ?>;"><?= $icon('fa-user', '👤') ?> <?= $this->e($activeLend['borrower_name']) ?></span>
— <?= $isOverdue ? '<strong style="color:var(--red-600);">' . $icon('fa-exclamation-triangle', '⚠️') . ' seit ' . date('d.m.Y', strtotime($activeLend['planned_end'])) . ' überfällig</strong>' : 'bis ' . date('d.m.Y', strtotime($activeLend['planned_end'])) ?>
</div>
<?php elseif ($nextRes && $status === 'available'): ?>
<div class="loan-device-sub" style="color:var(--amber-600);">
<?= $icon('fa-calendar', '📅') ?> <?= $this->te('Reserviert ab') ?> <?= date('d.m.Y', strtotime($nextRes['planned_start'])) ?>
(<?= $this->e($nextRes['borrower_name']) ?>)
</div>
<?php elseif ($status === 'reserved' && $nextRes): ?>
<div class="loan-device-sub" style="color:var(--amber-600);">
<?= $icon('fa-calendar', '📅') ?> <?= $this->e($nextRes['borrower_name']) ?>
<?= $this->te('ab') ?> <?= date('d.m.Y', strtotime($nextRes['planned_start'])) ?>
</div>
<?php else: ?>
<div class="loan-device-sub" style="color:var(--gray-500);"><?= $this->e($dev['type'] ?? '') ?></div>
<?php endif; ?>
<?php if (!empty($dev['pending_count'])): ?>
<div class="loan-device-sub" style="color:var(--blue-700);">
<?= $icon('fa-hourglass-half', '⏳') ?> <?= (int)$dev['pending_count'] ?> <?= $this->te((int)$dev['pending_count'] === 1 ? 'Anfrage – Freigabe offen' : 'Anfragen – Freigabe offen') ?>
</div>
<?php endif; ?>
</div>
<span class="loan-device-badge" style="background:<?= $sbg ?>;color:<?= $scol ?>;"><?= $slabel ?></span>
</a>
<?php endforeach; ?>
</div>
<?php if ($this->hasCapability('issue_loan_devices')): ?>
<div style="margin-top:0.75rem;text-align:right;">
<a href="/loans/issue" style="font-size:0.82rem;color:var(--amber-600);font-weight:600;"><?= $this->te('+ Gerät ausgeben') ?></a>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'my_tickets': ?>
<?php
$myTickets = $my_tickets ?? [];
$myTicketCounts = $my_ticket_counts ?? ['new' => 0, 'active' => 0];
$hasViewTickets = $has_view_tickets ?? false;
?>
<?php if ($hasViewTickets): ?>
<?= $this->render('dashboard/_widget_tickets', [
'my_tickets' => $myTickets,
'my_ticket_counts' => $myTicketCounts,
'has_view_tickets' => $hasViewTickets,
'icon_style' => $icon_style ?? 'fontawesome',
]) ?>
<script>
// Auto-Refresh für "Meine Tickets" – fragt den schlanken Endpoint
// /dashboard/tickets-widget ab (rendert nur diese Section, nicht das
// komplette Dashboard) und tauscht die Section aus. Läuft NUR, wenn
// das Widget gerendert wurde und der Tab sichtbar ist.
(function () {
var section = document.getElementById('wMyTickets');
if (!section || window._wMyTicketsPollStarted) return;
window._wMyTicketsPollStarted = true;
var INTERVAL = 30000;
function refresh() {
if (document.visibilityState === 'hidden') return;
var current = document.getElementById('wMyTickets');
if (!current) return; // Widget wurde inzwischen ausgeblendet
fetch('/dashboard/tickets-widget', {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html' },
credentials: 'same-origin'
})
.then(function (r) { return r.ok ? r.text() : null; })
.then(function (html) {
if (!html) return;
var doc = new DOMParser().parseFromString(html, 'text/html');
var next = doc.getElementById('wMyTickets');
var cur = document.getElementById('wMyTickets');
if (next && cur) cur.replaceWith(next);
})
.catch(function () { /* nächster Tick versucht es erneut */ });
}
setInterval(refresh, INTERVAL);
// Beim Wiedereinblenden des Tabs: sofort einmal aktualisieren.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') refresh();
});
})();
</script>
<?php endif; ?>
<?php break; ?>
<?php case 'teamboard': ?>
<section class="dashboard-card" data-widget="teamboard" id="wTeamboard">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-sticky-note', '🗒️') ?> <?= $this->te('Notizbrett') ?></h2>
</div>
<a href="/teamboard" class="card-link"><?= $this->te('Alle →') ?></a>
</div>
<div style="padding:0.75rem 1rem 0.9rem;">
<?php if (empty($teamboardNotes)): ?>
<p class="text-muted" style="margin:0;"><?= $this->te('Noch keine Notizen.') ?></p>
<?php else: ?>
<div class="tb-mini">
<?php foreach ($teamboardNotes as $_n): ?>
<?php
$_uid = (int)($_n['created_by'] ?? 0);
$_wer = (string)($_n['display_name'] ?? '') !== '' ? (string)$_n['display_name'] : $this->t('Gelöschtes Konto');
$_ts = strtotime((string)($_n['created_at'] ?? '')) ?: null;
$_stil = '--tb-color:' . \OpenNIT\Core\AuthorColor::css($_uid)
. ';--tb-soft:' . \OpenNIT\Core\AuthorColor::softCss($_uid) . ';';
?>
<div class="tb-mini-item" style="<?= $this->e($_stil) ?>">
<div class="tb-mini-head">
<span class="tb-mini-author"><?= $this->e($_wer) ?></span>
<span class="tb-mini-time"><?= $_ts ? $this->e(date('d.m. H:i', $_ts)) : '' ?></span>
</div>
<div class="tb-mini-body"><?= $this->e($_n['body']) ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php break; ?>
<?php case 'my_todos': ?>
<section class="dashboard-card" data-widget="my_todos" id="wTodos">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-check-square', '✅') ?> <?= $this->te('To-Do-Liste') ?></h2>
</div>
<a href="/todos" class="card-link"><?= $this->te('Alle →') ?></a>
</div>
<div style="padding:0.75rem 1rem 0.6rem;">
<!-- Schnell-Eingabe -->
<form onsubmit="wTodoAdd(event)" autocomplete="off" class="wtd-add-row">
<input id="wTodoInput" type="text" placeholder="<?= $this->te('Neue Aufgabe…') ?>" maxlength="500" class="wtd-add-input">
<input id="wTodoDueIn" type="date" class="wtd-add-due" title="<?= $this->te('Fälligkeitsdatum') ?>">
<button aria-label="<?= $this->te('Hinzufügen') ?>" type="submit" class="wtd-add-btn">+</button>
</form>
<!-- Liste -->
<ul id="wTodoList" class="wtd-list">
<?php if (empty($myTodos)): ?>
<li id="wTodoEmpty" class="wtd-empty"><?= $this->te('Keine offenen Aufgaben') ?> <?= $icon('fa-check-circle', '✔️') ?></li>
<?php else: ?>
<?php
$_wtoday = date('Y-m-d');
foreach ($myTodos as $t):
$_isOver = !empty($t['due_date']) && $t['due_date'] < $_wtoday;
$_isToday = !empty($t['due_date']) && $t['due_date'] === $_wtoday;
$_isSoon = !empty($t['due_date']) && $t['due_date'] === date('Y-m-d', strtotime('+1 day'));
$_dueCls = $_isOver ? 'wtd-due--overdue' : ($_isToday ? 'wtd-due--today' : ($_isSoon ? 'wtd-due--soon' : ''));
$_dueFmt = !empty($t['due_date']) ? date('d.m.Y', strtotime($t['due_date'])) : '';
$_duePfx = $_isOver ? 'Überfällig · ' : ($_isToday ? 'Heute · ' : ($_isSoon ? 'Morgen · ' : ''));
?>
<li class="wtd-item<?= $_isOver ? ' wtd-item--overdue' : '' ?>"
data-id="<?= (int)$t['id'] ?>" data-due="<?= $this->e($t['due_date'] ?? '') ?>" draggable="true">
<span class="wtd-drag" title="<?= $this->te('Ziehen') ?>">⋮⋮</span>
<label class="wtd-check">
<input type="checkbox" class="wtd-cb" onchange="wTodoDone(this)">
<span class="wtd-cm"></span>
</label>
<div class="wtd-content">
<span class="wtd-title" ondblclick="wTodoStartEdit(this)"><?= $this->e($t['title']) ?></span>
<input type="text" class="wtd-edit-inp" style="display:none"
onblur="wTodoSaveEdit(this)" onkeydown="wTodoEditKey(event,this)" maxlength="500">
<?php if (!empty($t['due_date'])): ?>
<span class="wtd-due-badge <?= $_dueCls ?>"><?= $_duePfx ?><?= $_dueFmt ?></span>
<?php endif; ?>
<input type="date" class="wtd-due-inp" style="display:none"
onblur="wTodoSaveDue(this)" onchange="wTodoSaveDue(this)"
value="<?= $this->e($t['due_date'] ?? '') ?>">
<?php if (!empty($t['notes'])): ?>
<span class="wtd-notes"><?= $this->e($t['notes']) ?></span>
<?php endif; ?>
<textarea class="wtd-notes-inp" style="display:none"
onblur="wTodoSaveNotes(this)" placeholder="<?= $this->te('Notiz…') ?>" maxlength="2000"><?= $this->e($t['notes'] ?? '') ?></textarea>
</div>
<div class="wtd-actions">
<button class="wtd-act-btn<?= !empty($t['due_date']) ? ' active' : '' ?>" onclick="wTodoToggleDue(this)" title="<?= $this->te('Fälligkeitsdatum') ?>">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="12" height="11" rx="1.5"/><line x1="5" y1="1" x2="5" y2="5"/><line x1="11" y1="1" x2="11" y2="5"/><line x1="2" y1="7" x2="14" y2="7"/></svg>
</button>
<button class="wtd-act-btn<?= !empty($t['notes']) ? ' active' : '' ?>" onclick="wTodoToggleNotes(this)" title="<?= $this->te('Notiz') ?>">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5a1.414 1.414 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/></svg>
</button>
<button class="wtd-act-btn wtd-del" onclick="wTodoDelete(this)" title="<?= $this->te('Löschen') ?>">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>
</button>
</div>
</li>
<?php endforeach; ?>
<?php if (count($myTodos) >= 10): ?>
<li style="font-size:0.72rem;color:var(--gray-400);text-align:center;padding:0.3rem 0;">
<a href="/todos" style="color:var(--indigo-500);"><?= $this->te('Weitere ansehen →') ?></a>
</li>
<?php endif; ?>
<?php endif; ?>
</ul>
<!-- Footer -->
<?php if ($myTodosDoneCount > 0): ?>
<div style="margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid var(--gray-100);font-size:0.72rem;color:var(--gray-400);text-align:center;">
<?= $icon('fa-check', '✓') ?> <?= $this->te('{anzahl} erledigt', ['anzahl' => $myTodosDoneCount]) ?> ·
<a href="/todos" style="color:var(--indigo-500);"><?= $this->te('Alle anzeigen') ?></a>
</div>
<?php endif; ?>
</div>
</section>
<?php break; ?>
<?php case 'my_kanban_tasks': ?>
<?php if ($hasViewKanban): ?>
<?php
$nowMonth = date('Y-m');
$nextMonth = date('Y-m', strtotime('+1 month'));
$overdue = array_filter($myKanbanTasks, fn($t) => $t['month'] < $nowMonth && !$t['done']);
$current = array_filter($myKanbanTasks, fn($t) => $t['month'] === $nowMonth);
$upcoming = array_filter($myKanbanTasks, fn($t) => $t['month'] === $nextMonth);
$monthLabel = fn(string $m): string => (new \DateTime($m . '-01'))->format('F Y');
$priorityColor = [
'urgent' => ['#fee2e2','#991b1b'],
'high' => ['#fef3c7','#92400e'],
'normal' => ['#f3f4f6','#374151'],
'low' => ['#f0fdf4','#166534'],
];
$priorityLabel = ['urgent'=>'Dringend','high'=>'Hoch','normal'=>'Normal','low'=>'Niedrig'];
$renderTask = function(array $t) use ($priorityColor, $priorityLabel): string {
[$bg, $fg] = $priorityColor[$t['priority']] ?? ['#f3f4f6','#374151'];
$done = $t['done'] ? 'text-decoration:line-through;opacity:0.55;' : '';
$categoryDot = '';
if (!empty($t['category_color'])) {
$c = htmlspecialchars($t['category_color']);
$n = htmlspecialchars($t['category_name'] ?? '');
$categoryDot = "<span title=\"$n\" style=\"display:inline-block;width:8px;height:8px;border-radius:50%;background:$c;flex-shrink:0;\"></span>";
}
$title = htmlspecialchars($t['title']);
$pLabel = $priorityLabel[$t['priority']] ?? $t['priority'];
$dueHtml = '';
if (!empty($t['due_date'])) {
$due = new \DateTime($t['due_date']);
$isOverdue = !$t['done'] && $due < new \DateTime('today');
$dueColor = $isOverdue ? '#dc2626' : '#9ca3af';
$dueHtml = '<span style="font-size:0.68rem;color:'.$dueColor.';white-space:nowrap;">'.$due->format('d.m.').'</span>';
}
return '<a href="/kanban" style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;border-radius:0.4rem;background:var(--gray-50);text-decoration:none;border:1px solid var(--gray-100);">'
. $categoryDot
. '<span style="flex:1;font-size:0.82rem;font-weight:600;color:var(--gray-900);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'.$done.'">'.$title.'</span>'
. $dueHtml
. '<span style="font-size:0.65rem;font-weight:700;padding:0.12rem 0.45rem;border-radius:9999px;background:'.$bg.';color:'.$fg.';white-space:nowrap;">'.$pLabel.'</span>'
. '</a>';
};
$totalCount = count($overdue) + count($current) + count($upcoming);
?>
<section class="dashboard-card" data-widget="my_kanban_tasks">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-clipboard-list', '📋') ?> <?= $this->te('Meine Aufgaben') ?></h2>
</div>
<a href="/kanban" class="card-link"><?= $this->te('Kanban →') ?></a>
</div>
<div class="card-body">
<?php if ($totalCount === 0): ?>
<p style="font-size:0.85rem;color:var(--gray-400);text-align:center;padding:0.75rem 0;"><?= $this->te('Keine Aufgaben zugewiesen.') ?></p>
<?php else: ?>
<?php if (!empty($overdue)): ?>
<div style="margin-bottom:0.9rem;">
<div style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.4rem;">
<span style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--red-600);"><?= $icon('fa-exclamation-triangle', '⚠️') ?> <?= $this->te('Überfällig') ?></span>
<span style="font-size:0.7rem;color:var(--red-600);background:var(--red-100);border-radius:9999px;padding:0.05rem 0.45rem;font-weight:700;"><?= count($overdue) ?></span>
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;">
<?php foreach ($overdue as $t): echo $renderTask($t); endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($current)): ?>
<div style="margin-bottom:0.9rem;">
<div style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.4rem;">
<span style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--color-primary);"><?= $this->te('Dieser Monat') ?></span>
<span style="font-size:0.7rem;color:var(--color-primary-strong);background:var(--color-primary-soft);border-radius:9999px;padding:0.05rem 0.45rem;font-weight:700;"><?= count($current) ?></span>
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;">
<?php foreach ($current as $t): echo $renderTask($t); endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($upcoming)): ?>
<div>
<div style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.4rem;">
<span style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;color:var(--emerald-600);"><?= $this->te('Nächster Monat') ?></span>
<span style="font-size:0.7rem;color:var(--emerald-800);background:var(--emerald-100);border-radius:9999px;padding:0.05rem 0.45rem;font-weight:700;"><?= count($upcoming) ?></span>
</div>
<div style="display:flex;flex-direction:column;gap:0.3rem;">
<?php foreach ($upcoming as $t): echo $renderTask($t); endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'monitoring': ?>
<?php if ($hasViewMonitoring): ?>
<section class="dashboard-card <?= (int)($monitoringCounts['critical'] ?? 0) > 0 ? 'attention' : '' ?>" data-widget="monitoring">
<div class="card-header">
<h2 class="card-title">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<?= $icon('fa-chart-bar', '📊') ?> Monitoring
<?php if ((int)($monitoringCounts['critical'] ?? 0) > 0): ?>
<span style="background:var(--red-600);color:var(--color-on-accent);font-size:.7rem;padding:.15rem .5rem;border-radius:9999px;margin-left:.4rem">
<?= $this->te('{anzahl} kritisch', ['anzahl' => (int)$monitoringCounts['critical']]) ?>
</span>
<?php elseif ((int)($monitoringCounts['active'] ?? 0) > 0): ?>
<span style="background:var(--amber-500);color:var(--color-on-accent);font-size:.7rem;padding:.15rem .5rem;border-radius:9999px;margin-left:.4rem">
<?= $this->te('{anzahl} aktiv', ['anzahl' => (int)$monitoringCounts['active']]) ?>
</span>
<?php endif; ?>
</h2>
<a href="/monitoring" class="card-action"><?= $this->te('Alle →') ?></a>
</div>
<?php if (empty($monitoringEvents)): ?>
<div style="padding:1.25rem 1rem;text-align:center;color:var(--gray-500);font-size:.85rem">
<span style="font-size:1.5rem"><?= $icon('fa-check', '✓') ?></span><br><?= $this->te('Keine aktiven Probleme') ?>
</div>
<?php else: ?>
<div style="overflow:hidden">
<?php
$sevColors = [
'disaster' => ['#dc2626', '#fef2f2'],
'high' => ['#ea580c', '#fff7ed'],
'average' => ['#d97706', '#fffbeb'],
'warning' => ['#ca8a04', '#fefce8'],
'information' => ['#3c8dbc', '#e8f4fd'],
'not_classified' => ['#6b7280', '#f3f4f6'],
];
$sevLabels = ['disaster'=>'DISASTER','high'=>'HIGH','average'=>'AVG','warning'=>'WARN','information'=>'INFO','not_classified'=>'N/C'];
foreach ($monitoringEvents as $me):
[$fg, $bg] = $sevColors[$me['severity']] ?? ['#6b7280', '#f3f4f6'];
$sinceMin = (int)((time() - strtotime($me['created_at'])) / 60);
$since = $sinceMin < 60 ? $sinceMin.'m' : (round($sinceMin/60,1).'h');
?>
<div style="display:flex;align-items:center;gap:.5rem;padding:.45rem 1rem;border-bottom:1px solid var(--gray-100)">
<span style="flex-shrink:0;font-size:.7rem;font-weight:700;color:<?= $fg ?>;background:<?= $bg ?>;padding:.15rem .45rem;border-radius:.25rem;min-width:42px;text-align:center">
<?= $sevLabels[$me['severity']] ?? '?' ?>
</span>
<span style="flex:1;min-width:0;font-size:.8rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<strong><?= $this->e($me['host']) ?></strong>
<span style="color:var(--gray-500)"> – <?= $this->e(mb_strimwidth($me['trigger_name'], 0, 50, '…')) ?></span>
</span>
<span style="flex-shrink:0;font-size:.72rem;color:var(--gray-400)"><?= $since ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'backup': ?>
<?php if ($hasViewBackup): ?>
<?php $bkFailed = (int)($backupCounts['failed'] ?? 0); $bkWarn = (int)($backupCounts['warning'] ?? 0); ?>
<section class="dashboard-card <?= $bkFailed > 0 ? 'attention' : '' ?>" data-widget="backup">
<div class="card-header">
<h2 class="card-title">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<?= $icon('fa-database', '💾') ?> Backup
<?php if ($bkFailed > 0): ?>
<span style="background:var(--red-600);color:var(--color-on-accent);font-size:.7rem;padding:.15rem .5rem;border-radius:9999px;margin-left:.4rem">
<?= $this->te('{anzahl} fehlgeschlagen', ['anzahl' => $bkFailed]) ?>
</span>
<?php elseif ($bkWarn > 0): ?>
<span style="background:var(--amber-500);color:var(--color-on-accent);font-size:.7rem;padding:.15rem .5rem;border-radius:9999px;margin-left:.4rem">
<?= $bkWarn ?> <?= $this->te('Warnung') ?>
</span>
<?php endif; ?>
</h2>
<a href="/backup" class="card-action"><?= $this->te('Alle →') ?></a>
</div>
<?php if (empty($backupJobs)): ?>
<div style="padding:1.25rem 1rem;text-align:center;color:var(--gray-500);font-size:.85rem">
<span style="font-size:1.5rem"><?= $icon('fa-check', '✓') ?></span><br>
<?= $this->te((int)($backupCounts['total'] ?? 0) > 0 ? 'Alle Backups in Ordnung' : 'Noch keine Backup-Daten') ?>
</div>
<?php else: ?>
<div style="overflow:hidden">
<?php foreach ($backupJobs as $bj):
$isFail = $bj['last_status'] === 'failed';
[$fg, $bg] = $isFail ? ['#dc2626', '#fef2f2'] : ['#ca8a04', '#fefce8'];
$when = $bj['last_run'] ? date('d.m. H:i', strtotime($bj['last_run'])) : '—';
?>
<div style="display:flex;align-items:center;gap:.5rem;padding:.45rem 1rem;border-bottom:1px solid var(--gray-100)">
<span style="flex-shrink:0;font-size:.7rem;font-weight:700;color:<?= $fg ?>;background:<?= $bg ?>;padding:.15rem .45rem;border-radius:.25rem;min-width:42px;text-align:center">
<?= $isFail ? 'FAIL' : 'WARN' ?>
</span>
<span style="flex:1;min-width:0;font-size:.8rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<strong><?= $this->e($bj['name']) ?></strong>
<?php if (!empty($bj['server_name'])): ?><span style="color:var(--gray-400)"> · <?= $this->e($bj['server_name']) ?></span><?php endif; ?>
</span>
<span style="flex-shrink:0;font-size:.72rem;color:var(--gray-400)"><?= $when ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'maintenance_upcoming': ?>
<?php if ($hasViewMaintenance): ?>
<?php
$_mSrcLabels = ['manual' => 'Manuell', 'ssl' => 'SSL', 'asset' => 'Garantien', 'contract' => 'Verträge', 'license' => 'Lizenzen', 'configlog' => 'Änderungsprotokoll'];
$_mSrcColors = ['manual' => '#1d4ed8', 'ssl' => '#dc2626', 'asset' => '#d97706', 'contract' => '#7c3aed', 'license' => '#059669', 'configlog' => '#6366f1'];
?>
<section class="dashboard-card <?= $maintenanceUrgentCount > 0 ? 'attention' : '' ?>" data-widget="maintenance_upcoming">
<div class="card-header" style="position:relative;">
<h2 class="card-title">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<?= $icon('fa-calendar', '📅') ?> <?= $this->te('Wartungstermine') ?>
<!-- Badge wird vom Lazy-Loader gefüllt (Anzahl erst nach Body-Fetch bekannt) -->
<span id="mtUrgentBadge" style="display:none;background:var(--red-600);color:var(--color-on-accent);font-size:.7rem;padding:.15rem .5rem;border-radius:9999px;margin-left:.4rem"></span>
</h2>
<div style="display:flex;align-items:center;gap:0.5rem;">
<button class="mf-toggle-btn" onclick="mfTogglePanel(this)" title="<?= $this->te('Quellen filtern') ?>"
style="background:none;border:none;cursor:pointer;color:var(--gray-400);padding:0.2rem 0.3rem;border-radius:0.3rem;line-height:1;font-size:0.8rem;display:flex;align-items:center;gap:0.25rem;transition:color 0.1s,background 0.1s;">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="4" x2="15" y2="4"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="5" y1="12" x2="11" y2="12"/></svg>
<?= $this->te('Filter') ?>
</button>
<a href="/maintenance" class="card-action"><?= $this->te('Kalender →') ?></a>
</div>
<!-- Filter-Popover (wird per JS an body gehängt, um overflow:hidden der Card zu umgehen) -->
<div class="mf-panel" id="mfPanel" style="display:none;position:absolute;">
<p class="mf-panel-title"><?= $this->te('Angezeigte Quellen') ?></p>
<?php
// Alle Quellen, die im Kalender auftauchen können – nur jene
// zeigen, deren Modul aktiv ist (sonst entstehen ohnehin keine
// Termine). Muss mit der Whitelist in
// DashboardController::saveMaintenanceFilter() übereinstimmen.
$_mfPanelSources = [
'manual' => ['Manuelle Termine', true],
'ssl' => ['SSL-Zertifikate', true],
'asset' => ['Asset-Garantien', $dashModules['assets'] ?? true],
'contract' => ['Verträge & Fristen', $dashModules['contracts'] ?? true],
'license' => ['Lizenzen', $dashModules['licenses'] ?? true],
'configlog' => ['Änderungsprotokoll', $dashModules['configlog'] ?? true],
];
foreach ($_mfPanelSources as $_src => [$_lbl, $_enabled]):
if (!$_enabled) continue;
?>
<label class="mf-option">
<input type="checkbox" class="mf-cb" value="<?= $_src ?>"
<?= in_array($_src, $maintenanceSources, true) ? 'checked' : '' ?>
onchange="mfSave()">
<span class="mf-dot" style="background:<?= $_mSrcColors[$_src] ?>"></span>
<?= $_lbl ?>
</label>
<?php endforeach; ?>
</div>
</div>
<!-- Body wird per Lazy-Load nachgeladen (/dashboard/widget/maintenance),
damit die teure UNION-Abfrage nicht den ersten Seitenaufbau blockiert. -->
<div id="mtBody" data-lazy="maintenance">
<div style="padding:1.25rem 1rem;text-align:center;color:var(--gray-400);font-size:.82rem">
<span class="dash-spinner" aria-hidden="true"></span> <?= $this->te('Termine werden geladen…') ?>
</div>
</div>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'phonebook_search': ?>
<?php if ($has_view_phonebook || ($has_view_contacts ?? false)): ?>
<section class="dashboard-card" data-widget="phonebook_search" style="overflow:visible;">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-phone', '📞') ?> <?= $this->te('Kontakte') ?></h2>
</div>
<?php if ($has_view_phonebook): ?>
<a href="/phonebook" class="card-link"><?= $this->te('Telefonbuch →') ?></a>
<?php elseif ($has_view_contacts ?? false): ?>
<a href="/contacts" class="card-link"><?= $this->te('Adressbuch →') ?></a>
<?php endif; ?>
</div>
<div class="card-body" style="padding:.75rem;overflow:visible;">
<div style="position:relative;">
<input type="text" id="pb-widget-input" class="form-control form-control-sm"
placeholder="<?= $this->te('Name, Firma, Telefon …') ?>" autocomplete="off">
</div>
</div>
</section>
<script>
(function(){
var inp = document.getElementById('pb-widget-input');
if (!inp) return;
var hasPhonebook = <?= json_encode((bool)$has_view_phonebook) ?>;
var hasContacts = <?= json_encode((bool)($has_view_contacts ?? false)) ?>;
var res = document.createElement('div');
res.id = 'pb-widget-results';
res.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.15);max-height:320px;overflow-y:auto;';
document.body.appendChild(res);
function positionDropdown() {
var rect = inp.getBoundingClientRect();
res.style.top = (rect.bottom + window.scrollY + 2) + 'px';
res.style.left = (rect.left + window.scrollX) + 'px';
res.style.width = rect.width + 'px';
}
function renderResult(p) {
var av = p.image_path
? '<img src="'+p.image_path+'" style="width:38px;height:38px;border-radius:50%;object-fit:cover;flex-shrink:0;" loading="lazy">'
: '<div style="width:38px;height:38px;border-radius:50%;background:'+p.avatar_color+';display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:.85rem;flex-shrink:0;">'+p.initials+'</div>';
var sub = [p.job_title, p.department].filter(Boolean).join(' · ');
var badge = p.source === 'contact'
? '<span style="font-size:.65rem;background:#e0f2fe;color:#0369a1;border-radius:4px;padding:1px 5px;white-space:nowrap;flex-shrink:0;">Adressbuch</span>'
: '<span style="font-size:.65rem;background:#f0fdf4;color:#166534;border-radius:4px;padding:1px 5px;white-space:nowrap;flex-shrink:0;">Telefonbuch</span>';
var _pbPhone = <?= $_useFA ? '"<i class=\"fa-solid fa-phone\"></i>"' : '"📞"' ?>;
var _pbMobile = <?= $_useFA ? '"<i class=\"fa-solid fa-mobile-alt\"></i>"' : '"📱"' ?>;
var _pbEmail = <?= $_useFA ? '"<i class=\"fa-solid fa-envelope\"></i>"' : '"✉"' ?>;
var phones = '';
if (p.phone) phones += '<a href="tel:'+p.phone.replace(/[^\d+]/g,'')+'" style="color:#1d4ed8;text-decoration:none;font-size:.78rem;white-space:nowrap;" onclick="event.stopPropagation()">'+_pbPhone+' '+p.phone+'</a> ';
if (p.mobile) phones += '<a href="tel:'+p.mobile.replace(/[^\d+]/g,'')+'" style="color:#059669;text-decoration:none;font-size:.78rem;white-space:nowrap;" onclick="event.stopPropagation()">'+_pbMobile+' '+p.mobile+'</a> ';
if (p.email) phones += '<a href="mailto:'+p.email+'" style="color:#6b7280;text-decoration:none;font-size:.78rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:160px;display:inline-block;" onclick="event.stopPropagation()">'+_pbEmail+' '+p.email+'</a>';
var row = '<div style="display:flex;align-items:center;gap:.6rem;padding:.5rem .75rem;border-bottom:1px solid #f3f4f6;"'
+ (p.link ? ' style="cursor:pointer;" onclick="window.location=\''+p.link+'\'"' : '') + '>'
+ av
+ '<div style="min-width:0;flex:1;">'
+ '<div style="display:flex;align-items:center;gap:.4rem;">'
+ '<span style="font-weight:600;font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'+p.display_name+'</span>'
+ (hasPhonebook && hasContacts ? badge : '')
+ '</div>'
+ (sub ? '<div style="font-size:.72rem;color:#6b7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'+sub+'</div>' : '')
+ (phones ? '<div style="margin-top:.2rem;display:flex;flex-wrap:wrap;gap:.35rem;">'+phones+'</div>' : '')
+ '</div></div>';
return row;
}
var timer;
inp.addEventListener('input', function(){
clearTimeout(timer);
var q = inp.value.trim();
if (q.length < 2) { res.style.display='none'; res.innerHTML=''; return; }
timer = setTimeout(function(){
var qs = encodeURIComponent(q);
var fetches = [];
if (hasPhonebook) fetches.push(fetch('/api/phonebook/search?q='+qs).then(function(r){ return r.ok ? r.json() : []; }).catch(function(){ return []; }));
if (hasContacts) fetches.push(fetch('/api/contacts/search?q='+qs).then(function(r){ return r.ok ? r.json() : []; }).catch(function(){ return []; }));
Promise.all(fetches).then(function(results){
var data = [].concat.apply([], results);
data.sort(function(a,b){ return a.display_name.localeCompare(b.display_name, 'de'); });
if (!data.length) {
res.innerHTML='<div style="padding:.6rem 1rem;color:#9ca3af;font-size:.85rem;">Keine Ergebnisse</div>';
} else {
res.innerHTML = data.map(renderResult).join('');
}
positionDropdown(); res.style.display='block';
});
}, 250);
});
inp.addEventListener('focus', function(){
if (res.innerHTML && inp.value.trim().length >= 2) {
positionDropdown(); res.style.display='block';
}
});
window.addEventListener('scroll', function(){ if (res.style.display!=='none') positionDropdown(); }, true);
window.addEventListener('resize', function(){ if (res.style.display!=='none') positionDropdown(); });
document.addEventListener('click', function(e){
if (!inp.contains(e.target) && !res.contains(e.target)) { res.style.display='none'; }
});
})();
</script>
<?php endif; ?>
<?php break; ?>
<?php case 'cve_security': ?>
<?php if ($has_view_cve ?? false): ?>
<?php
$_cveFilter = $cve_widget_filter ?? 'all';
$_cveIsFiltered = $_cveFilter === 'filtered';
?>
<section class="dashboard-card" data-widget="cve_security">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-shield-alt', '🛡') ?> <?= $this->te('CVE-Meldungen') ?></h2>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;">
<!-- Filter-Toggle (Zustand in data-filter, ohne Reload) -->
<button id="cveWidgetToggle"
onclick="toggleCveWidgetFilter()"
data-filter="<?= $this->e($_cveFilter) ?>"
aria-pressed="<?= $_cveIsFiltered ? 'true' : 'false' ?>"
title="<?= $_cveIsFiltered ? 'Zeige alle CVEs' : 'Zeige nur Keywords' ?>"
style="background:<?= $_cveIsFiltered ? '#eff6ff' : '#f3f4f6' ?>;
color:<?= $_cveIsFiltered ? '#1d4ed8' : '#6b7280' ?>;
border:1px solid <?= $_cveIsFiltered ? '#bfdbfe' : '#e5e7eb' ?>;
border-radius:9999px;padding:0.15rem 0.6rem;
font-size:0.72rem;font-weight:600;cursor:pointer;white-space:nowrap;">
<?= $_cveIsFiltered ? $icon('fa-search', '🔍') . ' Keywords' : 'Alle' ?>
</button>
<a href="/cve" class="card-link"><?= $this->te('Anzeigen →') ?></a>
</div>
</div>
<!-- Body wird per Lazy-Load nachgeladen (/dashboard/widget/cve) -->
<div id="cveBody" data-lazy="cve" class="card-body" style="padding-bottom:0.5rem;">
<div style="padding:1rem;text-align:center;color:var(--gray-400);font-size:.82rem">
<span class="dash-spinner" aria-hidden="true"></span> <?= $this->te('CVE-Daten werden geladen…') ?>
</div>
</div>
</section>
<script>
// Filter-Toggle ohne Voll-Reload: Setting speichern, Button-Zustand
// aktualisieren, CVE-Body neu laden.
var CVE_FILTER_ICON = <?= $_useFA ? '"<i class=\"fa-solid fa-search\"></i> Keywords"' : '"🔍 Keywords"' ?>;
function toggleCveWidgetFilter() {
var btn = document.getElementById('cveWidgetToggle');
var next = btn.dataset.filter === 'filtered' ? 'all' : 'filtered';
fetch('/api/user-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content,
'X-Requested-With': 'XMLHttpRequest'
},
body: 'key=cve_widget_filter&value=' + encodeURIComponent(next)
}).then(function(r) {
if (!r.ok) return;
var filtered = next === 'filtered';
btn.dataset.filter = next;
btn.setAttribute('aria-pressed', filtered ? 'true' : 'false');
btn.title = filtered ? 'Zeige alle CVEs' : 'Zeige nur Keywords';
btn.style.background = filtered ? '#eff6ff' : '#f3f4f6';
btn.style.color = filtered ? '#1d4ed8' : '#6b7280';
btn.style.borderColor = filtered ? '#bfdbfe' : '#e5e7eb';
btn.innerHTML = filtered ? CVE_FILTER_ICON : 'Alle';
if (window.loadWidgetBody) window.loadWidgetBody('cve');
else window.location.reload();
});
}
</script>
<?php endif; ?>
<?php break; ?>
<?php case 'unifi_vouchers': ?>
<?php if ($unifiModuleEnabled && $hasManageUnifiVouchers): ?>
<?php
$_uvwSubmitLabel = $icon('fa-plus', '➕') . ' Voucher erstellen';
$_uvwLabelCfg = $voucher_label ?? [];
?>
<script src="<?= $this->asset('/assets/js/voucher-label.js') ?>"></script>
<script>window.VoucherLabelConfig = <?= json_encode([
'printMethod' => $_uvwLabelCfg['print_method'] ?? 'both',
'logoUrl' => $_uvwLabelCfg['logo_url'] ?? '',
'geo' => $_uvwLabelCfg['geo'] ?? [],
'apiBase' => '/unifi/vouchers/dymo-label',
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;</script>
<section class="dashboard-card" data-widget="unifi_vouchers">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-wifi', '📶') ?> <?= $this->te('UniFi Voucher') ?></h2>
</div>
<a href="/unifi/vouchers" class="card-link"><?= $this->te('Übersicht →') ?></a>
</div>
<div class="card-body">
<?php if (empty($unifiVoucherSites)): ?>
<p style="font-size:0.82rem;color:var(--gray-400);text-align:center;padding:0.5rem 0;">
<?= $this->t('Noch keine Sites bekannt. Bitte zuerst auf der <a href="/unifi">UniFi-Übersicht</a> aktualisieren.') ?>
</p>
<?php else: ?>
<div class="form-group" style="margin-bottom:.6rem;">
<label class="uvw-label" for="uvw-site"><?= $this->te('Site') ?></label>
<select id="uvw-site" class="form-control form-control-sm">
<?php foreach ($unifiVoucherSites as $s): ?>
<option value="<?= $this->e($s['site_id']) ?>"><?= $this->e($s['site_name'] ?: $s['site_id']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.6rem;margin-bottom:.6rem;">
<div class="form-group" style="margin:0;">
<label class="uvw-label" for="uvw-hours"><?= $this->te('Stunden') ?></label>
<input type="number" id="uvw-hours" class="form-control form-control-sm" value="24" min="1" max="720">
</div>
<div class="form-group" style="margin:0;">
<label class="uvw-label" for="uvw-count"><?= $this->te('Geräte') ?></label>
<input type="number" id="uvw-count" class="form-control form-control-sm" value="1" min="1" max="100">
</div>
</div>
<div class="form-group" style="margin-bottom:.7rem;">
<label class="uvw-label" for="uvw-note"><?= $this->te('Notiz') ?> <span style="font-weight:400;color:var(--gray-400);"><?= $this->te('(optional)') ?></span></label>
<input type="text" id="uvw-note" class="form-control form-control-sm" maxlength="64" placeholder="<?= $this->te('z. B. Gast-Konferenz') ?>">
</div>
<button type="button" class="btn btn-primary btn-block btn-sm uvw-submit" onclick="uvwCreate()">
<?= $_uvwSubmitLabel ?>
</button>
<div id="uvw-inline-error" class="uvw-result uvw-err" style="display:none;margin-top:.6rem;"></div>
<?php endif; ?>
</div>
</section>
<!-- Modal: zeigt den erstellten Voucher-Code -->
<div id="uvwModalBackdrop" class="uvw-modal-backdrop" onclick="uvwCloseModal(event)" style="display:none;">
<div class="uvw-modal" onclick="event.stopPropagation()">
<div class="uvw-modal-header">
<h3 id="uvwModalTitle"><?= $icon('fa-check-circle', '✔️') ?> <?= $this->te('Voucher erstellt') ?></h3>
<button type="button" class="uvw-modal-close" onclick="uvwCloseModal()" aria-label="<?= $this->te('Schließen') ?>">×</button>
</div>
<div class="uvw-modal-body">
<p id="uvwModalMeta" class="uvw-modal-meta"></p>
<div id="uvwModalCode" class="uvw-modal-bigcode"></div>
</div>
<div class="uvw-modal-footer">
<button type="button" id="uvwCopyBtn" class="btn btn-primary btn-sm" onclick="uvwCopySingle()">
<?= $icon('fa-clipboard-list', '📋') ?> <?= $this->te('Code kopieren') ?>
</button>
<span id="uvwPrintBtns" style="display:inline-flex;gap:.4rem;"></span>
<button type="button" class="btn btn-default btn-sm" onclick="uvwCloseModal()"><?= $this->te('Schließen') ?></button>
</div>
</div>
</div>
<script>
(function(){
if (window.uvwCreate) return;
var SUBMIT_HTML = <?= json_encode($_uvwSubmitLabel) ?>;
function fmtCode(c) {
var s = String(c).replace(/[-\s]/g, '');
return s.length >= 10 ? s.slice(0, 5) + '-' + s.slice(5) : c;
}
function showInlineError(msg) {
var el = document.getElementById('uvw-inline-error');
if (!el) return;
el.style.display = 'block';
el.textContent = msg;
}
function hideInlineError() {
var el = document.getElementById('uvw-inline-error');
if (el) el.style.display = 'none';
}
window.uvwOpenModal = function(siteName, hours, devices, codes) {
var bd = document.getElementById('uvwModalBackdrop');
var meta = document.getElementById('uvwModalMeta');
var box = document.getElementById('uvwModalCode');
var btn = document.getElementById('uvwCopyBtn');
if (!bd || !meta || !box) return;
var code = fmtCode(codes[0] || '');
var devLabel = devices === 1 ? '1 Gerät' : devices + ' Geräte';
meta.textContent = 'Voucher für „' + siteName + '" · gültig ' + hours + ' h · ' + devLabel;
box.textContent = code;
box.dataset.code = code;
var pb = document.getElementById('uvwPrintBtns');
if (pb) pb.innerHTML = window.VoucherLabel ? VoucherLabel.buttons(code, { btnClass: 'btn btn-default btn-sm' }) : '';
if (btn) {
btn.disabled = false;
btn.classList.remove('uvw-copied');
btn.innerHTML = '<?= $icon('fa-clipboard-list', '📋') ?> <?= $this->te('Code kopieren') ?>';
}
bd.style.display = 'flex';
document.body.style.overflow = 'hidden';
};
window.uvwCloseModal = function(e) {
if (e && e.target && e.target.id !== 'uvwModalBackdrop') return;
var bd = document.getElementById('uvwModalBackdrop');
if (bd) bd.style.display = 'none';
document.body.style.overflow = '';
};
window.uvwCopySingle = function() {
var box = document.getElementById('uvwModalCode');
var btn = document.getElementById('uvwCopyBtn');
if (!box || !navigator.clipboard) return;
var code = box.dataset.code || box.textContent || '';
navigator.clipboard.writeText(code).then(function(){
if (!btn) return;
btn.classList.add('uvw-copied');
btn.innerHTML = '✓ Kopiert!';
setTimeout(function(){
btn.classList.remove('uvw-copied');
btn.innerHTML = '<?= $icon('fa-clipboard-list', '📋') ?> <?= $this->te('Code kopieren') ?>';
}, 1800);
}).catch(function(){
if (btn) btn.innerHTML = '✗ Fehler beim Kopieren';
});
};
// Code-Anzeige selbst klickbar machen → kopiert auch
document.addEventListener('click', function(e){
if (e.target && e.target.id === 'uvwModalCode') uvwCopySingle();
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape') uvwCloseModal();
});
window.uvwCreate = function() {
hideInlineError();
var siteEl = document.getElementById('uvw-site');
var hoursEl = document.getElementById('uvw-hours');
var countEl = document.getElementById('uvw-count');
var noteEl = document.getElementById('uvw-note');
if (!siteEl || !hoursEl || !countEl) return;
var siteId = siteEl.value;
var siteName = siteEl.options[siteEl.selectedIndex]?.text || siteId;
var hours = parseInt(hoursEl.value, 10);
var devices = parseInt(countEl.value, 10);
var note = (noteEl?.value || '').trim();
if (!siteId || isNaN(hours) || hours < 1 || isNaN(devices) || devices < 1 || devices > 200) {
showInlineError('Bitte Site, gültige Stunden und 1–200 Geräte angeben.');
return;
}
var btn = document.querySelector('#widgetGrid [data-widget="unifi_vouchers"] .uvw-submit');
if (btn) { btn.disabled = true; btn.innerHTML = '⏳ Wird erstellt…'; }
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrf = csrfMeta ? csrfMeta.content : '';
// "Geräte" = Anzahl der zulässigen Nutzungen pro Voucher (quota),
// wir erzeugen immer genau einen Voucher (n=1).
var body = new URLSearchParams({
_csrf: csrf,
site_id: siteId,
expire: String(hours * 60),
n: '1',
quota: String(devices),
down: '0',
up: '0',
note: note
});
fetch('/unifi/vouchers', { method: 'POST', body: body, headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) {
var codes = (d.vouchers || []).filter(function(c){ return c && c.trim() !== ''; });
if (codes.length === 0) {
showInlineError('Voucher erstellt – Code konnte nicht ermittelt werden.');
} else {
uvwOpenModal(siteName, hours, devices, codes);
if (noteEl) noteEl.value = '';
}
} else {
showInlineError('Fehler: ' + (d.error || 'Unbekannt'));
}
})
.catch(function(e){ showInlineError('Netzwerkfehler: ' + e.message); })
.finally(function(){
if (btn) { btn.disabled = false; btn.innerHTML = SUBMIT_HTML; }
});
};
})();
</script>
<?php endif; ?>
<?php break; ?>
<?php case 'contracts_expiring': ?>
<?php if ($hasViewContracts ?? false): ?>
<?php
$today_ts = strtotime('today');
$ceUrgent = 0;
foreach ($contractsExpiring as $ce) {
$dl = (int)floor((strtotime($ce['cancellation_notice_date']) - $today_ts) / 86400);
if ($dl <= 14) $ceUrgent++;
}
?>
<section class="dashboard-card <?= $ceUrgent > 0 ? 'attention' : '' ?>" data-widget="contracts_expiring">
<div class="card-header">
<h2 class="card-title">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<?= $icon('fa-file-contract', '📄') ?> <?= $this->te('Kündigungsfristen') ?>
<?php if ($ceUrgent > 0): ?>
<span style="background:var(--red-600);color:var(--color-on-accent);font-size:.7rem;padding:.15rem .5rem;border-radius:9999px;margin-left:.4rem">
<?= $this->te('{anzahl} dringend', ['anzahl' => $ceUrgent]) ?>
</span>
<?php endif; ?>
</h2>
<a href="/contracts?view=cancel_soon" class="card-action"><?= $this->te('Alle →') ?></a>
</div>
<?php if (empty($contractsExpiring)): ?>
<div style="padding:1.25rem 1rem;text-align:center;color:var(--gray-500);font-size:.85rem">
<span style="font-size:1.5rem"><?= $icon('fa-check', '✓') ?></span><br>
<?= $this->te('Keine Kündigungsfristen in den nächsten 60 Tagen') ?>
</div>
<?php else: ?>
<div style="overflow:hidden">
<?php foreach ($contractsExpiring as $ce):
$daysLeft = (int)floor((strtotime($ce['cancellation_notice_date']) - $today_ts) / 86400);
[$fg, $bg] = $daysLeft <= 7 ? ['#dc2626', '#fef2f2'] : ($daysLeft <= 30 ? ['#d97706', '#fffbeb'] : ['#2471a3', '#eff6ff']);
?>
<a href="/contracts/<?= (int)$ce['id'] ?>" style="display:flex;align-items:center;gap:.5rem;padding:.45rem 1rem;border-bottom:1px solid var(--gray-100);text-decoration:none;color:inherit">
<span style="flex-shrink:0;font-size:.7rem;font-weight:700;color:<?= $fg ?>;background:<?= $bg ?>;padding:.15rem .45rem;border-radius:.25rem;min-width:54px;text-align:center">
<?= $daysLeft <= 0 ? 'heute' : $daysLeft . ' Tg' ?>
</span>
<span style="flex:1;min-width:0;font-size:.8rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<strong><?= $this->e($ce['title']) ?></strong>
<span style="color:var(--gray-400)"> · <?= $this->e($ce['company_name']) ?></span>
</span>
<span style="flex-shrink:0;font-size:.72rem;color:var(--gray-400)"><?= date('d.m.Y', strtotime($ce['cancellation_notice_date'])) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php break; ?>
<?php case 'workflow_approvals': ?>
<section class="dashboard-card" data-widget="workflow_approvals">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="widget-drag-handle" title="<?= $this->te('Verschieben') ?>">⋮⋮</span>
<h2><?= $icon('fa-clipboard-check', '✅') ?> <?= $this->te('Genehmigungen') ?></h2>
</div>
<a href="/antraege" class="card-link"><?= $this->te('Alle →') ?></a>
</div>
<div class="card-body">
<?php $wfA = $workflow_approvals ?? []; ?>
<?php if (empty($wfA)): ?>
<p style="font-size:0.85rem;color:var(--gray-400);text-align:center;padding:0.75rem 0;"><?= $this->te('Nichts zu genehmigen.') ?></p>
<?php else: ?>
<div style="display:flex;flex-direction:column;gap:0.3rem;">
<?php foreach ($wfA as $row): ?>
<a href="/antraege/<?= (int)$row['request_id'] ?>" style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.4rem 0.6rem;border-radius:0.4rem;background:var(--gray-50);text-decoration:none;border:1px solid var(--gray-100);">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--gray-700);">
<?= htmlspecialchars($row['title']) ?>
<span style="color:var(--gray-400);font-size:0.72rem;">· <?= htmlspecialchars($row['step_name']) ?></span>
<?php if (!empty($row['via_deputy'])): ?><span style="color:var(--violet-600);font-size:0.68rem;"><?= $this->te('(Vertretung)') ?></span><?php endif; ?>
</span>
<?php if (!empty($row['deadline_at'])): ?>
<?php $od = strtotime($row['deadline_at']) < time(); ?>
<span style="font-size:0.68rem;white-space:nowrap;color:<?= $od ? 'var(--red-600)' : 'var(--gray-400)' ?>;"><?= (new \DateTime($row['deadline_at']))->format('d.m.') ?></span>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php break; ?>
<?php endswitch; ?>
<?php endforeach; ?>
</div><!-- /dashCol -->
<?php endforeach; ?>
</div><!-- /widgetGrid -->
<!-- Statistiken -->
<footer class="dashboard-stats-footer">
<span class="dsf-item">
<span class="dsf-value"><?= number_format($stats['total_pages'] ?? 0) ?></span>
<span class="dsf-label"><?= $this->te('Seiten') ?></span>
</span>
<span class="dsf-sep">·</span>
<span class="dsf-item">
<span class="dsf-value"><?= number_format($stats['total_topics'] ?? 0) ?></span>
<span class="dsf-label"><?= $this->te('Themengebiete') ?></span>
</span>
<span class="dsf-sep">·</span>
<span class="dsf-item">
<span class="dsf-value"><?= number_format($stats['my_pages'] ?? 0) ?></span>
<span class="dsf-label"><?= $this->te('Meine Seiten') ?></span>
</span>
<?php if (($stats['pending_reviews'] ?? 0) > 0): ?>
<span class="dsf-sep">·</span>
<span class="dsf-item dsf-warn">
<span class="dsf-value"><?= number_format($stats['pending_reviews'] ?? 0) ?></span>
<span class="dsf-label"><?= $this->te('Offene Reviews') ?></span>
</span>
<?php endif; ?>
</footer>
</div>
</div>
<!-- Widget-Konfig-Panel (Slide-over) -->
<div id="widgetConfigBackdrop" class="wcfg-backdrop" onclick="closeWidgetConfig()"></div>
<div id="widgetConfigPanel" class="wcfg-panel" role="dialog" aria-modal="true" aria-labelledby="widgetConfigTitle" aria-hidden="true">
<div class="wcfg-header">
<h3 id="widgetConfigTitle"><?= $this->te('Dashboard anpassen') ?></h3>
<button aria-label="<?= $this->te('Schließen') ?>" onclick="closeWidgetConfig()" class="wcfg-close">×</button>
</div>
<div id="wcfgList" class="wcfg-list">
<!-- Hinweis -->
<p style="font-size:0.8rem;color:var(--gray-500);margin:0 0 0.75rem;"><?= $this->te('Widgets ein-/ausblenden. Spalte und Reihenfolge direkt per Drag & Drop auf dem Dashboard ändern.') ?></p>
<!-- Spaltenanzahl -->
<div style="margin-bottom:1.25rem;padding-bottom:1.25rem;border-bottom:1px solid var(--gray-200);">
<p style="font-size:0.8rem;font-weight:600;color:var(--gray-700);margin:0 0 0.5rem;"><?= $this->te('Spaltenanzahl') ?></p>
<div style="display:flex;gap:0.5rem;" id="wcfgColBtns">
<?php foreach ([1 => '1 Spalte', 2 => '2 Spalten', 3 => '3 Spalten'] as $n => $lbl): ?>
<button
type="button"
class="wcfg-col-btn <?= $dashboardColumns === $n ? 'active' : '' ?>"
data-cols="<?= $n ?>"
onclick="setDashboardColumns(<?= $n ?>)"
style="flex:1;padding:0.4rem 0.5rem;border-radius:0.4rem;border:1px solid var(--gray-300);background:<?= $dashboardColumns === $n ? 'var(--color-primary)' : 'var(--gray-50)' ?>;color:<?= $dashboardColumns === $n ? 'var(--color-bg)' : 'var(--gray-700)' ?>;font-size:0.8rem;cursor:pointer;transition:all 0.15s;">
<?= $lbl ?>
</button>
<?php endforeach; ?>
</div>
</div>
<?php foreach ($allWidgets as $wId):
$isHidden = in_array($wId, $hiddenWidgets, true);
$wCol = $widgetColumnMap[$wId] ?? 1;
$label = $widgetLabels[$wId][0] ?? $wId;
$desc = $widgetLabels[$wId][1] ?? '';
?>
<div class="wcfg-item <?= $isHidden ? 'hidden' : 'visible' ?>" draggable="true"
data-widget="<?= $wId ?>" data-col="<?= $wCol ?>">
<span class="wcfg-handle">⋮⋮</span>
<div class="wcfg-item-text">
<span class="wcfg-item-label"><?= $label ?></span>
<span class="wcfg-item-desc"><?= $this->e($desc) ?></span>
</div>
<button class="wcfg-toggle" onclick="toggleWidget(this)" title="<?= $this->te('Ein-/ausblenden') ?>"
aria-pressed="<?= $isHidden ? 'false' : 'true' ?>"
aria-label="<?= $this->e(strip_tags($label)) ?> <?= $this->te('ein- oder ausblenden') ?>">
<span class="wcfg-eye"><?= $isHidden ? $icon('fa-eye-slash', '🚫') : $icon('fa-eye', '👁') ?></span>
</button>
</div>
<?php endforeach; ?>
</div>
<div class="wcfg-footer">
<button onclick="saveWidgetConfig()" class="wcfg-save-btn"><?= $this->te('Speichern') ?></button>
</div>
</div>
<!-- Schnellzugriff-Konfig-Panel -->
<div id="qaConfigBackdrop" class="wcfg-backdrop" onclick="closeQAConfig()"></div>
<div id="qaConfigPanel" class="wcfg-panel" role="dialog" aria-modal="true" aria-labelledby="qaConfigTitle" aria-hidden="true">
<div class="wcfg-header">
<h3 id="qaConfigTitle"><?= $icon('fa-bolt', '🚀') ?> <?= $this->te('Schnellzugriff anpassen') ?></h3>
<button aria-label="<?= $this->te('Schließen') ?>" onclick="closeQAConfig()" class="wcfg-close">×</button>
</div>
<div id="qaList" class="wcfg-list">
<p style="font-size:0.8rem;color:var(--gray-500);margin:0 0 0.75rem;"><?= $this->te('Wähle welche Kacheln angezeigt werden sollen. Reihenfolge per Drag & Drop.') ?></p>
<!-- Spaltenanzahl -->
<div style="margin-bottom:1.25rem;padding-bottom:1.25rem;border-bottom:1px solid var(--gray-200);">
<p style="font-size:0.8rem;font-weight:600;color:var(--gray-700);margin:0 0 0.5rem;"><?= $this->te('Spaltenanzahl') ?></p>
<div style="display:flex;gap:0.5rem;" id="qaColBtns">
<?php foreach ([2 => '2 Spalten', 3 => '3 Spalten'] as $n => $lbl): ?>
<button type="button"
data-cols="<?= $n ?>"
onclick="setQACols(<?= $n ?>)"
style="flex:1;padding:0.4rem 0.5rem;border-radius:0.4rem;border:1px solid var(--gray-300);background:<?= $quickAccessCols === $n ? 'var(--color-primary)' : 'var(--gray-50)' ?>;color:<?= $quickAccessCols === $n ? 'var(--color-bg)' : 'var(--gray-700)' ?>;font-size:0.8rem;cursor:pointer;transition:all 0.15s;">
<?= $lbl ?>
</button>
<?php endforeach; ?>
</div>
</div>
<?php
// Aktive zuerst (gespeicherte Reihenfolge), danach inaktive
$qaInactive = array_values(array_diff(array_keys($availableQuickLinks), $activeQuickLinkKeys));
$qaOrdered = array_merge($activeQuickLinkKeys, $qaInactive);
foreach ($qaOrdered as $key):
if (!isset($availableQuickLinks[$key])) continue;
$ql = $availableQuickLinks[$key];
$isActive = in_array($key, $activeQuickLinkKeys, true);
?>
<div class="wcfg-item <?= $isActive ? 'visible' : 'hidden' ?>" draggable="true" data-qakey="<?= $this->e($key) ?>">
<span class="wcfg-handle">⋮⋮</span>
<div class="wcfg-item-text">
<span class="wcfg-item-label"><?= $icon($ql['icon']) ?> <?= $this->te($ql['label']) ?></span>
<span class="wcfg-item-desc"><?= $this->te($ql['desc']) ?></span>
</div>
<button class="wcfg-toggle" onclick="toggleQAItem(this)" title="<?= $this->te('Ein-/ausblenden') ?>"
aria-pressed="<?= $isActive ? 'true' : 'false' ?>"
aria-label="<?= $this->e($ql['label']) ?> <?= $this->te('ein- oder ausblenden') ?>">
<span class="wcfg-eye"><?= $isActive ? $icon('fa-eye', '👁') : $icon('fa-eye-slash', '🚫') ?></span>
</button>
</div>
<?php endforeach; ?>
</div>
<div class="wcfg-footer">
<button onclick="saveQAConfig()" class="wcfg-save-btn"><?= $this->te('Speichern') ?></button>
</div>
</div>
<!-- Dashboard-CSS ausgelagert nach public/assets/css/dashboard.css (per extra_css eingebunden, browser-cachebar) -->
<!-- Dashboard-Kern-JS ausgelagert nach public/assets/js/dashboard.js (browser-cachebar) -->
<script>window.__DASHBOARD__ = {
hiddenWidgets: <?= json_encode($hiddenWidgets) ?>,
dashCols: <?= (int)$dashboardColumns ?>,
qaCols: <?= (int)$quickAccessCols ?>
};</script>
<script src="/assets/js/dashboard.js?v=<?= filemtime(dirname(__DIR__, 2) . '/public/assets/js/dashboard.js') ?>" defer></script>