Quelltext des aktuellen Standes
<?php
$this->layout('layouts/main', [
'page_title' => 'Vertragsmanagement',
'current_route' => 'contracts',
'full_width' => true,
]);
$statusLabels = ['active' => 'Aktiv', 'expiring' => 'Kündigung', 'expired' => 'Abgelaufen', 'cancelled' => 'Gekündigt', 'future' => 'Zukünftig'];
// Hilfsfunktion: Label für 'expiring' je nach Vertragstyp
$getExpiringLabel = function(array $contract) use ($statusLabels): string {
if ($contract['status'] === 'expiring'
&& empty($contract['auto_renew'])
&& empty($contract['cancellation_notice_date'])
) {
return 'Läuft aus';
}
return $statusLabels[$contract['status']] ?? $contract['status'];
};
$view_filter = $view_filter ?? '';
$payment_this_month_sum = $payment_this_month_sum ?? 0.0;
$payment_this_month_count= $payment_this_month_count ?? 0;
$cancel_soon_count = $cancel_soon_count ?? 0;
$auto_renew_count = $auto_renew_count ?? 0;
$category_filter = $category_filter ?? null;
$price_display = $price_display ?? 'brutto';
$priceLabel = $price_display === 'netto' ? 'Netto' : 'Brutto';
$show_archive = $show_archive ?? false;
$sort = $sort ?? null;
$dir = $dir ?? 'asc';
$hasFilters = ($search !== '' || $status_filter !== '' || $company_filter !== null || $category_filter !== null || $view_filter !== '');
// Aktuelle Filter für Sortier-Links erhalten
$baseQs = array_filter([
'q' => $search !== '' ? $search : null,
'status' => $status_filter !== '' ? $status_filter : null,
'company' => $company_filter !== null ? $company_filter : null,
'category' => $category_filter !== null ? $category_filter : null,
'archive' => $show_archive ? '1' : null,
], static fn($v) => $v !== null && $v !== '');
// Sortierbarer Tabellenkopf (nur in der Standardansicht, nicht in Schnellansichten)
$sortHeader = function (string $col, string $label) use ($sort, $dir, $baseQs, $view_filter): string {
if ($view_filter !== '') {
return htmlspecialchars($label);
}
$nextDir = ($sort === $col && $dir === 'asc') ? 'desc' : 'asc';
$href = '/contracts?' . http_build_query(array_merge($baseQs, ['sort' => $col, 'dir' => $nextDir]));
$arrow = $sort === $col ? ($dir === 'asc' ? ' ▲' : ' ▼') : '';
return '<a href="' . htmlspecialchars($href) . '" class="cv-sort' . ($sort === $col ? ' cv-sort-active' : '') . '">'
. htmlspecialchars($label) . $arrow . '</a>';
};
?>
<div class="cv-wrap">
<!-- Content Header -->
<div class="content-header">
<div>
<h1><?= $this->te('Vertragsmanagement') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item active"><?= $this->te('Verträge') ?></li>
</ol>
</div>
<div class="content-header-actions">
<?php
// Export-Buttons übernehmen die aktiven Filter (Suche/Status/Firma/Kategorie)
$csvQs = [];
if ($search !== '') $csvQs['q'] = $search;
if ($status_filter !== '') $csvQs['status'] = $status_filter;
if ($company_filter !== null) $csvQs['company'] = $company_filter;
if ($category_filter !== null) $csvQs['category'] = $category_filter;
$csvUrl = '/contracts/export/csv' . (!empty($csvQs) ? '?' . http_build_query($csvQs) : '');
// PDF ist ein Kostenreport (aktive/laufende Verträge) – Status-Filter ist
// dort nicht sinnvoll, Suche/Firma/Kategorie werden aber übernommen.
$pdfQs = $csvQs;
unset($pdfQs['status']);
$pdfUrl = '/contracts/export/pdf' . (!empty($pdfQs) ? '?' . http_build_query($pdfQs) : '');
?>
<?php if (!$show_archive): ?>
<a href="<?= $pdfUrl ?>" target="_blank" class="btn btn-default" title="<?= $this->te('Verträge als PDF exportieren') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><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('PDF-Export') ?>
</a>
<a href="<?= $csvUrl ?>" class="btn btn-default" title="<?= $this->te('Aktuelle Auswahl als CSV exportieren') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><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"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>
CSV
</a>
<?php $ical_feed_url = $ical_feed_url ?? null; ?>
<details class="cv-ical-menu">
<summary class="btn btn-default" title="<?= $this->te('Kündigungsfristen als Kalender') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<?= $this->te('Kalender') ?>
</summary>
<div class="cv-ical-pop">
<a href="/contracts/export/ical" class="cv-ical-row">
<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>
<span><?= $this->te('.ics herunterladen') ?> <small><?= $this->te('(einmalig)') ?></small></span>
</a>
<div class="cv-ical-sep"></div>
<?php if ($ical_feed_url): ?>
<div class="cv-ical-label"><?= $this->te('Abo-Link (in Kalender-App einfügen):') ?></div>
<div class="cv-ical-url-row">
<input type="text" id="cvIcalUrl" class="cv-ical-url" readonly value="<?= $this->e($ical_feed_url) ?>" onclick="this.select()">
<button type="button" class="cv-ical-copy" onclick="(function(b){navigator.clipboard&&navigator.clipboard.writeText(document.getElementById('cvIcalUrl').value);b.textContent='✓';setTimeout(function(){b.textContent='Kopieren';},1500);})(this)"><?= $this->te('Kopieren') ?></button>
</div>
<div class="cv-ical-actions">
<form action="/contracts/calendar/subscribe" method="POST" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="cv-ical-link"><?= $this->te('Link erneuern') ?></button>
</form>
<form action="/contracts/calendar/revoke" method="POST" style="margin:0;" onsubmit="return confirm('Abo-Link widerrufen? Bestehende Abonnements funktionieren dann nicht mehr.');">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="cv-ical-link cv-ical-link-danger"><?= $this->te('Widerrufen') ?></button>
</form>
</div>
<?php else: ?>
<div class="cv-ical-label"><?= $this->te('Kalender abonnieren – Fristen automatisch synchron halten:') ?></div>
<form action="/contracts/calendar/subscribe" method="POST" style="margin:0;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="cv-ical-row cv-ical-row-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14M5 12h14"/></svg>
<span><?= $this->te('Abo-Link erstellen') ?></span>
</button>
</form>
<?php endif; ?>
</div>
</details>
<?php endif; ?>
<a href="/contracts/categories" class="btn btn-default" title="<?= $this->te('Kategorien verwalten') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M4 6h16M4 12h8m-8 6h16"/></svg>
<?= $this->te('Kategorien') ?>
</a>
<?php if ($show_archive): ?>
<a href="/contracts" class="btn btn-default">
<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->te('Zurück zu Verträgen') ?>
</a>
<?php else: ?>
<a href="/contracts?archive=1" class="btn btn-default" title="<?= $this->te('Abgelaufene und beendete Verträge anzeigen') ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<?= $this->te('Archiv') ?>
</a>
<?php if ($can_manage): ?>
<a href="/contracts/create" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<?= $this->te('Neuer Vertrag') ?>
</a>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<div class="page-body" style="min-height:200px;">
<?php if ($show_archive): ?>
<!-- Archiv-Banner -->
<div class="cv-archive-banner">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<strong><?= $this->te('Archiv') ?></strong> – <?= $this->te('Abgelaufene und beendete Verträge') ?>
<span class="cv-archive-count"><?= $this->te(count($contracts) === 1 ? '{anzahl} Eintrag' : '{anzahl} Einträge', ['anzahl' => count($contracts)]) ?></span>
</div>
<?php endif; ?>
<?php if (!$show_archive): ?>
<!-- KPI Small Boxes -->
<div class="kpi-row">
<div class="kpi-col">
<div class="small-box bg-info">
<div class="inner">
<h3><?= $this->money($cost_summary['total_monthly'], 2) ?></h3>
<p><?= $this->te('Monatliche Kosten') ?> <span class="cv-price-badge"><?= $priceLabel ?></span></p>
</div>
<div class="icon"><i class="fas fa-euro-sign"></i></div>
<span class="small-box-footer cv-kpi-footer-empty"> </span>
</div>
</div>
<div class="kpi-col">
<div class="small-box bg-primary">
<div class="inner">
<h3><?= $this->money($cost_summary['total_yearly'], 2) ?></h3>
<p><?= $this->te('Jährliche Kosten') ?> <span class="cv-price-badge"><?= $priceLabel ?></span></p>
</div>
<div class="icon"><i class="fas fa-calendar-alt"></i></div>
<span class="small-box-footer cv-kpi-footer-empty"> </span>
</div>
</div>
<div class="kpi-col">
<a href="/contracts?status=active" class="cv-kpi-link">
<div class="small-box bg-success">
<div class="inner">
<h3><?= (int)($status_counts['active'] ?? 0) ?></h3>
<p><?= $this->te('Aktive Verträge') ?></p>
</div>
<div class="icon"><i class="fas fa-file-contract"></i></div>
<span class="small-box-footer"><?= $this->te('Anzeigen') ?> <i class="fas fa-arrow-circle-right"></i></span>
</div>
</a>
</div>
<div class="kpi-col">
<a href="/contracts?status=expiring" class="cv-kpi-link">
<div class="small-box <?= (int)($status_counts['expiring'] ?? 0) > 0 ? 'bg-warning' : 'bg-secondary' ?>">
<div class="inner">
<h3><?= (int)($status_counts['expiring'] ?? 0) ?></h3>
<p><?= $this->te('Kündigung beachten') ?></p>
</div>
<div class="icon"><i class="fas fa-exclamation-triangle"></i></div>
<span class="small-box-footer"><?= $this->te('Anzeigen') ?> <i class="fas fa-arrow-circle-right"></i></span>
</div>
</a>
</div>
</div>
<?php if ($cancel_soon_count > 0 && $view_filter !== 'cancel_soon'): ?>
<!-- Prominente Warnung: Kündigungsfristen laufen ab -->
<a href="/contracts?view=cancel_soon" class="cv-alert-banner">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span><?= $this->t($cancel_soon_count === 1 ? '<strong>{anzahl} Vertrag</strong> mit Kündigungsfrist in den nächsten 60 Tagen – jetzt prüfen' : '<strong>{anzahl} Verträge</strong> mit Kündigungsfrist in den nächsten 60 Tagen – jetzt prüfen', ['anzahl' => $cancel_soon_count]) ?></span>
<span class="cv-alert-cta"><?= $this->te('Anzeigen →') ?></span>
</a>
<?php endif; ?>
<?php endif; /* !$show_archive */ ?>
<!-- Filterleiste -->
<div class="card card-outline card-primary mb-3" >
<div class="filter-card-body">
<form action="/contracts" method="GET" id="cvFilterForm" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
<?php if ($show_archive): ?><input type="hidden" name="archive" value="1"><?php endif; ?>
<div style="position:relative;flex:1;min-width:200px;">
<input type="search" name="q" class="form-control" style="padding-left:2rem;" placeholder="<?= $this->te('Verträge durchsuchen…') ?>" value="<?= $this->e($search) ?>" autocomplete="off">
</div>
<select name="status" class="form-control" style="width:auto;" onchange="this.form.submit()">
<option value=""><?= $this->te('Alle Status') ?></option>
<option value="active" <?= $status_filter === 'active' ? 'selected' : '' ?>><?= $this->te('Aktiv') ?></option>
<option value="future" <?= $status_filter === 'future' ? 'selected' : '' ?>><?= $this->te('Zukünftig') ?></option>
<option value="expiring" <?= $status_filter === 'expiring' ? 'selected' : '' ?>><?= $this->te('Kündigung beachten / Läuft aus') ?></option>
<option value="expired" <?= $status_filter === 'expired' ? 'selected' : '' ?>><?= $this->te('Abgelaufen') ?></option>
<option value="cancelled" <?= $status_filter === 'cancelled' ? 'selected' : '' ?>><?= $this->te('Gekündigt') ?></option>
</select>
<?php if (!empty($companies)): ?>
<select name="company" class="form-control" style="width:auto;" onchange="this.form.submit()">
<option value=""><?= $this->te('Alle Firmen') ?></option>
<?php foreach ($companies as $comp): ?>
<option value="<?= $comp['id'] ?>" <?= $company_filter === (int)$comp['id'] ? 'selected' : '' ?>><?= $this->e($comp['name']) ?></option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<?php if (!empty($categories)): ?>
<select name="category" class="form-control" style="width:auto;" onchange="this.form.submit()">
<option value=""><?= $this->te('Alle Kategorien') ?></option>
<option value="-1" <?= $category_filter === -1 ? 'selected' : '' ?>><?= $this->te('Ohne Kategorie') ?></option>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>" <?= $category_filter === (int)$cat['id'] ? 'selected' : '' ?>>
<?= $this->e($cat['name']) ?> (<?= $cat['contract_count'] ?>)
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<?php if (!$show_archive): ?>
<select name="view" class="form-control" style="width:auto;" onchange="this.form.submit()">
<option value=""><?= $this->te('Schnellansicht…') ?></option>
<option value="payment_this_month" <?= $view_filter === 'payment_this_month' ? 'selected' : '' ?>>
💳 <?= $this->te('Zahlung diesen Monat ({anzahl} · {summe} {art})', ['anzahl' => $payment_this_month_count, 'summe' => $this->money($payment_this_month_sum, 2), 'art' => $priceLabel]) ?>
</option>
<option value="cancel_soon" <?= $view_filter === 'cancel_soon' ? 'selected' : '' ?>>
⚠️ <?= $this->te('Kündigung in 60 Tagen') ?><?= $cancel_soon_count > 0 ? ' (' . $cancel_soon_count . ')' : '' ?>
</option>
<option value="auto_renew" <?= $view_filter === 'auto_renew' ? 'selected' : '' ?>>
🔄 <?= $this->te('Auto-Verlängerung ({anzahl})', ['anzahl' => $auto_renew_count]) ?>
</option>
</select>
<?php endif; ?>
<button type="submit" class="btn btn-default"><?= $this->te('Suchen') ?></button>
<?php if ($hasFilters): ?>
<a href="/contracts" class="btn btn-default">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<?= $this->te('Filter zurücksetzen') ?>
</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Aktive Schnellansicht: Info-Banner -->
<?php if ($view_filter === 'payment_this_month' && count($contracts) > 0): ?>
<div class="cv-view-banner cv-view-banner-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg>
<?= $this->t(count($contracts) === 1 ? '<strong>{anzahl} Zahlung</strong> fällig im {monat}' : '<strong>{anzahl} Zahlungen</strong> fällig im {monat}', ['anzahl' => count($contracts), 'monat' => $this->e(date('F Y'))]) ?> ·
<?= $this->t('Gesamt: <strong>{summe}</strong>', ['summe' => $this->money($payment_this_month_sum, 2)]) ?>
</div>
<?php elseif ($view_filter === 'cancel_soon' && count($contracts) > 0): ?>
<div class="cv-view-banner cv-view-banner-orange">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<?= $this->t(count($contracts) === 1 ? '<strong>{anzahl} Vertrag</strong> mit Kündigungsfrist in den nächsten 60 Tagen' : '<strong>{anzahl} Verträge</strong> mit Kündigungsfrist in den nächsten 60 Tagen', ['anzahl' => count($contracts)]) ?>
</div>
<?php elseif ($view_filter === 'auto_renew'): ?>
<div class="cv-view-banner cv-view-banner-teal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<?= $this->t(count($contracts) === 1 ? '<strong>{anzahl} Vertrag</strong> mit automatischer Verlängerung' : '<strong>{anzahl} Verträge</strong> mit automatischer Verlängerung', ['anzahl' => count($contracts)]) ?>
</div>
<?php endif; ?>
<!-- Tabelle / Leer-Zustand -->
<?php if (empty($contracts)): ?>
<div class="empty-state">
<div class="cv-empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" width="44" height="44"><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"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<p class="cv-empty-title"><?= $hasFilters ? 'Keine Verträge gefunden' : 'Noch keine Verträge' ?></p>
<p class="cv-empty-hint"><?= $hasFilters ? 'Passen Sie die Filterkriterien an.' : 'Legen Sie den ersten Vertrag an.' ?></p>
<?php if ($can_manage && !$hasFilters): ?>
<a href="/contracts/create" class="btn btn-primary" style="margin-top:1rem;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<?= $this->te('Neuer Vertrag') ?>
</a>
<?php endif; ?>
</div>
<?php else: ?>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th scope="col" style="width:110px"><?= $sortHeader('status', 'Status') ?></th>
<th scope="col"><?= $sortHeader('title', 'Vertragsbezeichnung') ?></th>
<th scope="col" style="width:130px"><?= $sortHeader('category', 'Kategorie') ?></th>
<th scope="col"><?= $sortHeader('company', 'Firma') ?></th>
<th scope="col"><?= $this->te('Vertragsnr.') ?></th>
<th scope="col" class="cv-right" style="width:130px"><?= $sortHeader('cost', 'Kosten') ?> <span class="cv-th-price-label"><?= $priceLabel ?></span></th>
<?php if ($view_filter === 'payment_this_month'): ?>
<th scope="col" style="width:150px"><?= $this->te('Nächste Zahlung') ?></th>
<?php elseif ($view_filter === 'cancel_soon'): ?>
<th scope="col" style="width:150px"><?= $this->te('Kündigung bis') ?></th>
<?php elseif ($view_filter === 'auto_renew'): ?>
<th scope="col" style="width:150px"><?= $this->te('Verlängerung') ?></th>
<?php else: ?>
<th scope="col" style="width:195px"><?= $sortHeader('end_date', 'Laufzeit') ?></th>
<?php endif; ?>
<th scope="col" style="width:140px"><?= $this->te('Verantwortlich') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($contracts as $c): ?>
<tr class="cv-row clickable" onclick="window.location='/contracts/<?= $c['id'] ?>'" tabindex="0"
onkeydown="if(event.key==='Enter')window.location='/contracts/<?= $c['id'] ?>'">
<td>
<?php
$pillMap = ['active'=>'status-pill--success','expiring'=>'status-pill--warning','expired'=>'status-pill--danger','cancelled'=>'status-pill--neutral','future'=>'status-pill--purple'];
$dotMap = ['active'=>'status-dot--success','expiring'=>'status-dot--warning','expired'=>'status-dot--danger','cancelled'=>'','future'=>''];
?>
<span class="status-pill <?= $pillMap[$c['status']] ?? 'status-pill--neutral' ?>">
<span class="status-dot <?= $dotMap[$c['status']] ?? '' ?>"></span>
<?= $this->e($getExpiringLabel($c)) ?>
</span>
</td>
<td>
<a href="/contracts/<?= $c['id'] ?>" class="cv-link"><?= $this->e($c['title']) ?></a>
</td>
<td>
<?php if (!empty($c['category_name'])): ?>
<span class="cv-cat-pill" style="background:<?= $this->e($c['category_color']) ?>20;color:<?= $this->e($c['category_color']) ?>;border-color:<?= $this->e($c['category_color']) ?>40;">
<?= $this->e($c['category_name']) ?>
</span>
<?php else: ?>
<span class="cv-muted" style="font-size:0.8rem;">–</span>
<?php endif; ?>
</td>
<td class="cv-muted"><?= $this->e($c['company_name'] ?? '–') ?></td>
<td class="cv-muted cv-mono"><?= $this->e($c['contract_number'] ?? '–') ?></td>
<td class="cv-right cv-cost">
<?php if (!empty($c['cost_amount'])): ?>
<?php
$interval = $c['cost_interval'] ?? 'monthly';
// Originalbetrag (Brutto), bei Bedarf auf Netto umrechnen
$origAmount = (float)$c['cost_amount'];
if ($price_display === 'netto' && !empty($c['tax_rate']) && (float)$c['tax_rate'] > 0) {
$origAmount = $origAmount / (1 + (float)$c['tax_rate'] / 100);
}
?>
<?php if ($interval === 'one_time'): ?>
<?= $this->money($origAmount, 2) ?><span class="cv-cost-interval"> <?= $this->te('einmalig') ?></span>
<?php else: ?>
<?php
// Immer auf Monatsbasis anzeigen (vergleichbar über alle Zeilen)
$monthlyAmount = match ($interval) {
'yearly' => $origAmount / 12,
'quarterly' => $origAmount / 3,
default => $origAmount,
};
// Tatsächliche Zahlweise als Subzeile (nur wenn nicht monatlich)
$cycleLabel = match ($interval) {
'yearly' => 'jährlich',
'quarterly' => 'quartalsweise',
default => '',
};
?>
<?= $this->money($monthlyAmount, 2) ?><span class="cv-cost-interval"><?= $this->te('/Monat') ?></span>
<?php if ($cycleLabel !== ''): ?>
<span class="cv-cost-sub"><?= $this->money($origAmount, 2) ?> · <?= $cycleLabel ?></span>
<?php endif; ?>
<?php endif; ?>
<?php else: ?>
<span class="cv-muted">–</span>
<?php endif; ?>
</td>
<?php if ($view_filter === 'payment_this_month'): ?>
<td class="cv-muted cv-dates">
<?php if (!empty($c['next_payment_date'])): ?>
<?php
$npd = $c['next_payment_date'];
$daysLeft = (int)((strtotime($npd) - time()) / 86400);
$color = $daysLeft <= 3 ? '#dc2626' : ($daysLeft <= 7 ? '#d97706' : '#374151');
?>
<span style="color:<?= $color ?>;font-weight:600;"><?= date('d.m.Y', strtotime($npd)) ?></span>
<span class="cv-cost-interval"><?= $this->t('(in {tage} Tagen)', ['tage' => $daysLeft]) ?></span>
<?php else: ?>
<span class="cv-muted">–</span>
<?php endif; ?>
</td>
<?php elseif ($view_filter === 'cancel_soon'): ?>
<td class="cv-muted cv-dates">
<?php if (!empty($c['cancellation_notice_date'])): ?>
<?php
$cnd = $c['cancellation_notice_date'];
$daysLeft = (int)((strtotime($cnd) - time()) / 86400);
$color = $daysLeft <= 7 ? '#dc2626' : ($daysLeft <= 30 ? '#d97706' : '#374151');
?>
<span style="color:<?= $color ?>;font-weight:600;"><?= date('d.m.Y', strtotime($cnd)) ?></span>
<span class="cv-cost-interval"><?= $this->t('({tage} Tage)', ['tage' => $daysLeft]) ?></span>
<?php else: ?>
<span class="cv-muted">–</span>
<?php endif; ?>
</td>
<?php elseif ($view_filter === 'auto_renew'): ?>
<td class="cv-muted cv-dates">
<?php if (!empty($c['end_date'])): ?>
<span><?= $this->te('bis {datum}', ['datum' => date('d.m.Y', strtotime($c['end_date']))]) ?></span>
<?php else: ?>
<span style="color:var(--gray-500);"><?= $this->te('unbefristet') ?></span>
<?php endif; ?>
<?php if (!empty($c['cancellation_period_months'])): ?>
<br><span class="cv-cost-interval"><?= $this->t('Frist: {monate} Mon.', ['monate' => $c['cancellation_period_months']]) ?></span>
<?php endif; ?>
</td>
<?php else: ?>
<td class="cv-muted cv-dates">
<?php if (!empty($c['start_date']) || !empty($c['end_date'])): ?>
<span><?= !empty($c['start_date']) ? date('d.m.Y', strtotime($c['start_date'])) : '?' ?></span>
<span class="cv-date-sep">–</span>
<span><?= !empty($c['end_date']) ? date('d.m.Y', strtotime($c['end_date'])) : 'unbefristet' ?></span>
<?php else: ?>
–
<?php endif; ?>
</td>
<?php endif; ?>
<td class="cv-muted"><?= $this->e($c['responsible_name'] ?? '–') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="cv-footer">
<span class="cv-count"><?= $this->te(count($contracts) === 1 ? '{anzahl} Vertrag' : '{anzahl} Verträge', ['anzahl' => count($contracts)]) ?></span>
<?php if ($hasFilters): ?>
<a href="/contracts" class="cv-reset"><?= $this->te('Filter zurücksetzen') ?></a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<style>
/* ====================================================
Vertragsübersicht · Prefix: cv-
==================================================== */
.cv-wrap {
display: flex;
flex-direction: column;
}
/* ── Body / Tabelle: Standard .table-wrapper/.data-table aus app.css ── */
.cv-sort {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.cv-sort:hover { color: var(--color-primary); }
.cv-sort-active { color: var(--color-primary-deep); }
/* ── Kalender-Abo Dropdown ────────────────────────── */
.cv-ical-menu { position: relative; display: inline-block; }
.cv-ical-menu > summary { list-style: none; cursor: pointer; }
.cv-ical-menu > summary::-webkit-details-marker { display: none; }
.cv-ical-pop {
position: absolute; right: 0; top: calc(100% + 4px); z-index: 30;
width: 320px; background: var(--color-bg); border: 1px solid var(--gray-200);
border-radius: 0.375rem; box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 0.75rem; text-align: left;
}
.cv-ical-row {
display: flex; align-items: center; gap: 0.5rem; width: 100%;
padding: 0.5rem 0.4rem; color: var(--gray-700); text-decoration: none;
font-size: 0.85rem; border: none; background: transparent; cursor: pointer;
border-radius: 0.25rem;
}
.cv-ical-row:hover { background: var(--gray-100); text-decoration: none; color: var(--gray-900); }
.cv-ical-row small { color: var(--gray-400); }
.cv-ical-row-btn { text-align: left; }
.cv-ical-sep { height: 1px; background: var(--gray-100); margin: 0.4rem 0; }
.cv-ical-label { font-size: 0.72rem; color: var(--gray-500); margin: 0.25rem 0.4rem 0.4rem; }
.cv-ical-url-row { display: flex; gap: 0.35rem; padding: 0 0.4rem; }
.cv-ical-url {
flex: 1; min-width: 0; font-size: 0.72rem; padding: 0.35rem 0.5rem;
border: 1px solid var(--gray-300); border-radius: 0.25rem; color: var(--gray-700); background: var(--gray-50);
}
.cv-ical-copy {
flex-shrink: 0; font-size: 0.72rem; padding: 0.35rem 0.6rem;
border: 1px solid var(--color-primary); background: var(--color-primary); color: var(--color-on-accent);
border-radius: 0.25rem; cursor: pointer;
}
.cv-ical-copy:hover { background: var(--color-primary-dark); }
.cv-ical-actions { display: flex; gap: 0.75rem; padding: 0.5rem 0.4rem 0; }
.cv-ical-link {
font-size: 0.75rem; background: none; border: none; cursor: pointer;
color: var(--color-primary); padding: 0; text-decoration: underline;
}
.cv-ical-link-danger { color: var(--red-600); }
.cv-row:focus { outline: 2px solid var(--blue-500); outline-offset: -2px; }
/* ── Status-Badge ─────────────────────────────────── */
/* ── Zell-Styles ──────────────────────────────────── */
.cv-link {
color: var(--gray-900);
font-weight: 500;
text-decoration: none;
}
.cv-link:hover { color: var(--color-primary); text-decoration: underline; }
.cv-cat-pill {
display: inline-block; font-size: 0.67rem; font-weight: 600;
padding: 0.1rem 0.45rem; border-radius: 9999px;
border: 1px solid; margin-left: 0.35rem; vertical-align: middle;
letter-spacing: 0.02em; white-space: nowrap;
}
.cv-muted { color: var(--gray-500); }
.cv-right { text-align: right; }
.cv-cost {
font-weight: 600;
color: var(--gray-900);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.cv-cost-interval {
font-size: 0.75rem;
font-weight: 400;
color: var(--gray-400);
}
.cv-cost-sub {
display: block;
margin-top: 0.1rem;
font-size: 0.72rem;
font-weight: 400;
color: var(--gray-400);
white-space: nowrap;
}
.cv-mono { font-variant-numeric: tabular-nums; font-size: 0.825rem; }
.cv-dates {
font-size: 0.825rem;
white-space: nowrap;
}
.cv-date-sep { margin: 0 0.25rem; color: var(--gray-300); }
/* ── Footer ───────────────────────────────────────── */
.cv-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.875rem;
padding: 0 0.25rem;
}
.cv-count { font-size: 0.8rem; color: var(--gray-400); }
/* ── Leer-Zustand ─────────────────────────────────── */
.cv-empty-icon {
width: 72px;
height: 72px;
background: var(--gray-100);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-400);
margin-bottom: 1.25rem;
}
.cv-empty-title {
font-size: 1rem;
font-weight: 600;
color: var(--gray-700);
margin: 0 0 0.375rem;
}
.cv-empty-hint {
font-size: 0.85rem;
color: var(--gray-400);
margin: 0;
}
/* ── Aktive Ansicht Banner ────────────────────────── */
.cv-view-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1rem;
border-radius: 0.375rem;
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.cv-view-banner-blue { background: var(--blue-50); color: var(--blue-800); border: 1px solid var(--blue-200); }
.cv-view-banner-orange { background: var(--amber-50); color: var(--amber-800); border: 1px solid var(--amber-200); }
.cv-view-banner-teal { background: var(--teal-50); color: var(--emerald-800); border: 1px solid var(--emerald-200); }
/* ── KPI-Kachel als Link ──────────────────────────── */
.cv-kpi-link { display: block; text-decoration: none; color: inherit; }
.cv-kpi-link:hover { text-decoration: none; }
.cv-kpi-link:hover .small-box { transform: translateY(-2px); box-shadow: 0 4px 10px rgba(0,0,0,0.12); transition: all 0.15s; }
/* Leerer Footer auf den Kosten-Karten: nur als Höhen-Platzhalter, nicht interaktiv */
.cv-kpi-footer-empty { cursor: default; }
/* ── Alarm-Banner (Kündigungsfristen) ─────────────── */
.cv-alert-banner {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: var(--orange-50);
border: 1px solid var(--orange-300);
border-left: 4px solid var(--orange-600);
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: var(--orange-800);
text-decoration: none;
transition: background 0.15s;
}
.cv-alert-banner:hover { background: var(--orange-100); text-decoration: none; color: var(--orange-800); }
.cv-alert-banner svg { flex-shrink: 0; color: var(--orange-600); }
.cv-alert-cta { margin-left: auto; font-weight: 600; white-space: nowrap; }
/* ── Archiv-Banner ────────────────────────────────── */
.cv-archive-banner {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: var(--gray-100);
border: 1px solid var(--gray-300);
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--gray-700);
}
.cv-archive-banner svg { flex-shrink: 0; color: var(--gray-500); }
.cv-archive-count {
margin-left: auto;
background: var(--gray-200);
color: var(--gray-700);
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 600;
}
/* ── Preis-Badge (Brutto/Netto) ───────────────────── */
.cv-price-badge {
display: inline-block;
font-size: 0.6rem;
font-weight: 700;
background: rgba(255,255,255,0.25);
border: 1px solid rgba(255,255,255,0.4);
border-radius: 3px;
padding: 0.05rem 0.3rem;
letter-spacing: 0.04em;
vertical-align: middle;
margin-left: 0.3rem;
text-transform: uppercase;
}
.cv-th-price-label {
font-size: 0.6rem;
font-weight: 600;
background: var(--blue-100);
color: var(--blue-800);
border: 1px solid var(--blue-200);
border-radius: 3px;
padding: 0.05rem 0.3rem;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-left: 0.25rem;
vertical-align: middle;
}
/* ── Responsive ───────────────────────────────────── */
@media (max-width: 768px) {
.cv-select { width: 100%; }
}
@media (max-width: 480px) {
}
</style>