presseportale/resources/views/livewire/admin/invoices/index.blade.php
Kevin Adametz 9ee77c8e6b 5.4: <x-portal.table-pagination> + 13 Wrapper ersetzt
Buendelt den 13-fach byte-identischen Paginierungs-Fuss (border-t + p-4 um
->links('components.portal.pagination')) in eine Component mit :paginator-
Prop. Erzeugt identisches HTML -> kein visuelles Risiko.

Ersetzt in: users, invoices/index, payments/index (4x: Bestandsschutz,
Subscriptions, Purchases, Invoices), contacts/index, legal-requests/index,
presets/index, companies/index, footer-codes/index, customer/invoices,
customer/press-releases/index.

Bewusst BARE gelassen (kein Wrapper im Original): press-releases/index,
categories/index, press-kits/index. Alle Views kompilieren, 251 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:35:37 +00:00

328 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\Portal;
use App\Models\LegacyInvoice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $portalFilter = 'all';
public string $statusFilter = 'all';
public string $mappingFilter = 'all';
public string $pdfFilter = 'all';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function updatedMappingFilter(): void
{
$this->resetPage();
}
public function updatedPdfFilter(): void
{
$this->resetPage();
}
public function resetFilters(): void
{
$this->reset(['search', 'portalFilter', 'statusFilter', 'mappingFilter', 'pdfFilter']);
$this->resetPage();
}
public function with(): array
{
$baseQuery = LegacyInvoice::query();
$filteredQuery = $this->filteredQuery();
$supportsPdfGeneratedAt = $this->supportsPdfGeneratedAt();
return [
'invoices' => $filteredQuery
->with('user:id,name,email')
->latest('invoice_date')
->latest('id')
->paginate(50),
'statusOptions' => (clone $baseQuery)
->whereNotNull('status')
->distinct()
->orderBy('status')
->pluck('status')
->filter()
->values(),
'portalOptions' => collect([Portal::Presseecho, Portal::Businessportal24]),
'stats' => [
'count' => (clone $baseQuery)->count(),
'total_cents' => (int) (clone $baseQuery)->sum('total_cents'),
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count(),
'unmapped_count' => (clone $baseQuery)->whereNull('user_id')->count(),
'generated_pdf_count' => $supportsPdfGeneratedAt
? (clone $baseQuery)->whereNotNull('pdf_generated_at')->count()
: 0,
'filtered_count' => (clone $filteredQuery)->count(),
],
'supportsPdfGeneratedAt' => $supportsPdfGeneratedAt,
];
}
private function filteredQuery(): Builder
{
return LegacyInvoice::query()
->when(filled($this->search), function (Builder $query): void {
$search = trim($this->search);
$query->where(function (Builder $query) use ($search): void {
$query
->where('number', 'like', '%'.$search.'%')
->orWhere('legacy_id', $search)
->orWhere('legacy_user_id', $search)
->orWhereHas('user', function (Builder $query) use ($search): void {
$query
->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%');
});
});
})
->when($this->portalFilter !== 'all', fn (Builder $query) => $query->where('legacy_portal', $this->portalFilter))
->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter))
->when($this->mappingFilter === 'mapped', fn (Builder $query) => $query->whereNotNull('user_id'))
->when($this->mappingFilter === 'unmapped', fn (Builder $query) => $query->whereNull('user_id'))
->when($this->supportsPdfGeneratedAt() && $this->pdfFilter === 'generated', fn (Builder $query) => $query->whereNotNull('pdf_generated_at'))
->when($this->supportsPdfGeneratedAt() && $this->pdfFilter === 'pending', fn (Builder $query) => $query->whereNull('pdf_generated_at'));
}
private function supportsPdfGeneratedAt(): bool
{
return Schema::hasColumn('legacy_invoices', 'pdf_generated_at');
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Finanzen')"
:title="__('Legacy Rechnungen')"
:description="__('Legacy-Rechnungsarchiv mit read-only Übersicht, Filtern und PDF-Download. Der neue Stripe-Rechnungslauf folgt separat in Phase 8.')"
description-width="720px"
>
<x-slot:meta>
<span class="badge hub">{{ __('Legacy-Rechnungsarchiv') }}</span>
</x-slot:meta>
</x-portal.page-header>
@if ($stats['unmapped_count'] > 0)
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
{{ __(':count Legacy-Rechnungen konnten keinem neuen User zugeordnet werden. Sie bleiben im Archiv sichtbar und sollten im Rehearsal-Report fachlich geprüft werden.', ['count' => number_format($stats['unmapped_count'], 0, ',', '.')]) }}
</div>
</div>
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 lg:grid-cols-5">
<x-portal.stat-card variant="primary" :label="__('Rechnungen')" :value="number_format($stats['count'], 0, ',', '.')">
<x-slot:meta>{{ __('Archivdatensätze') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Archivsumme')" :value="number_format($stats['total_cents'] / 100, 2, ',', '.').' €'">
<x-slot:meta>{{ __('historisch') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Bezahlt')" :value="number_format($stats['paid_count'], 0, ',', '.')">
<x-slot:meta>{{ __('mit Zahldatum') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('Ohne User')" :value="number_format($stats['unmapped_count'], 0, ',', '.')">
<x-slot:meta>{{ __('zu mappen') }}</x-slot:meta>
</x-portal.stat-card>
@if ($supportsPdfGeneratedAt)
<x-portal.stat-card variant="ok" :label="__('PDF erzeugt')" :value="number_format($stats['generated_pdf_count'], 0, ',', '.')">
<x-slot:meta>{{ __('aus Archiv') }}</x-slot:meta>
</x-portal.stat-card>
@else
<x-portal.stat-card variant="muted" :label="__('PDF-Status')" :value="__('Migration offen')">
<x-slot:meta>{{ __('Schema-Update fehlt') }}</x-slot:meta>
</x-portal.stat-card>
@endif
</section>
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
<flux:button size="sm" variant="filled" icon="arrow-path" wire:click="resetFilters">
{{ __('Filter zurücksetzen') }}
</flux:button>
</div>
<div class="p-5 space-y-4">
<div class="grid gap-3 lg:grid-cols-6">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Rechnungsnr., Legacy-ID, User oder E-Mail suchen...') }}"
icon="magnifying-glass"
class="lg:col-span-2"
/>
<flux:select wire:model.live="portalFilter">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach ($portalOptions as $portal)
<option value="{{ $portal->value }}">{{ $portal->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="statusFilter">
<option value="all">{{ __('Alle Status') }}</option>
@foreach ($statusOptions as $status)
<option value="{{ $status }}">{{ $status }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="mappingFilter">
<option value="all">{{ __('Alle Zuordnungen') }}</option>
<option value="mapped">{{ __('Mit User') }}</option>
<option value="unmapped">{{ __('Ohne User') }}</option>
</flux:select>
<flux:select wire:model.live="pdfFilter">
<option value="all">{{ __('Alle PDFs') }}</option>
@if ($supportsPdfGeneratedAt)
<option value="generated">{{ __('PDF erzeugt') }}</option>
<option value="pending">{{ __('Noch nicht erzeugt') }}</option>
@endif
</flux:select>
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __(':count Treffer für die aktuelle Filterung. PDF-Dateien werden bei Bedarf aus den archivierten Legacy-Daten erzeugt.', ['count' => number_format($stats['filtered_count'], 0, ',', '.')]) }}
</p>
</div>
</article>
{{-- ============== TABELLE ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Legacy-Rechnungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Treffer', ['count' => number_format($stats['filtered_count'], 0, ',', '.')]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Rechnungsnr.') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Betrag') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
<flux:table.column>{{ __('PDF') }}</flux:table.column>
</flux:table.columns>
@forelse ($invoices as $invoice)
<flux:table.row wire:key="admin-legacy-invoice-{{ $invoice->id }}">
<flux:table.cell>
<div class="space-y-0.5">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $invoice->number ?? ('#'.$invoice->legacy_id) }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] font-mono">Legacy-ID: {{ $invoice->legacy_id }}</div>
</div>
</flux:table.cell>
<flux:table.cell>
<span class="badge hub">{{ $invoice->legacy_portal?->label() }}</span>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->user)
<div class="space-y-0.5">
<flux:button
size="xs"
variant="filled"
:href="route('admin.users.show', $invoice->user)"
wire:navigate
>
{{ $invoice->user->name }}
</flux:button>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $invoice->user->email }}</div>
</div>
@else
<div class="space-y-1">
<span class="badge warn dot">{{ __('Ohne Zuordnung') }}</span>
<div class="text-[11px] text-[color:var(--color-ink-3)] font-mono">Legacy-User: {{ $invoice->legacy_user_id ?? 'n/a' }}</div>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)] tabular-nums">{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} €</span>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->paid_at)
<span class="badge ok dot">{{ $invoice->status ?? __('Bezahlt') }}</span>
@else
<span class="badge warn dot">{{ $invoice->status ?? __('Offen') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="space-y-0.5">
<div class="text-[12px] text-[color:var(--color-ink-2)]">{{ $invoice->invoice_date?->format('d.m.Y') ?? '–' }}</div>
@if ($invoice->paid_at)
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('bezahlt: :date', ['date' => $invoice->paid_at->format('d.m.Y')]) }}</div>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-2">
<flux:button
size="sm"
variant="filled"
icon="arrow-top-right-on-square"
:href="route('admin.legacy-invoices.pdf', $invoice)"
target="_blank"
>
{{ __('Öffnen') }}
</flux:button>
@if ($supportsPdfGeneratedAt && $invoice->pdf_generated_at)
<span class="badge ok">{{ __('erzeugt') }}</span>
@endif
</div>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.document-text class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Legacy-Rechnungen für diese Filter gefunden.') }}
</div>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
<x-portal.table-pagination :paginator="$invoices" />
</article>
</div>