presseportale/resources/views/livewire/admin/invoices/index.blade.php
Kevin Adametz 2cde7284a1 livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

355 lines
17 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\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';
/**
* `hold` = im Altsystem nie bezahlt, ersatzlos erledigt (16.09.2026).
* Bleibt im Archiv, wird aber standardmäßig ausgeblendet.
*/
public bool $showHold = false;
public function updatedShowHold(): void
{
$this->resetPage();
}
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', 'showHold']);
$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,
'hold_count' => (clone $baseQuery)->where('status', LegacyInvoice::STATUS_HOLD)->count(),
'open_legacy_count' => (clone $baseQuery)->openLegacy()->count(),
'filtered_count' => (clone $filteredQuery)->count(),
],
'supportsPdfGeneratedAt' => $supportsPdfGeneratedAt,
];
}
private function filteredQuery(): Builder
{
return LegacyInvoice::query()
// hold nur zeigen, wenn ausdrücklich gewünscht oder direkt gefiltert.
->when(! $this->showHold && $this->statusFilter !== LegacyInvoice::STATUS_HOLD, fn (Builder $query) => $query->withoutHold())
->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="__('Read-only-Archiv der Alt-Rechnungen mit Filtern und PDF-Download. Neue Rechnungen (Stripe und manueller Kreis) laufen unter Zahlungen. Nie bezahlte Alt-Rechnungen (hold) sind erledigt und standardmäßig ausgeblendet.')"
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>
<div class="flex flex-wrap items-center justify-between gap-3">
<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>
<flux:checkbox
wire:model.live="showHold"
:label="__(':count erledigte hold-Rechnungen (nie bezahlt) einblenden', ['count' => number_format($stats['hold_count'], 0, ',', '.')])"
/>
</div>
</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>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_HOLD)
<span class="badge" title="{{ __('Nie bezahlt — ersatzlos erledigt') }}">{{ $invoice->status }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_SUPERSEDED)
<span class="badge hub" title="{{ __('Ersetzt durch :number', ['number' => $invoice->raw_snapshot['superseded_by_invoice_number'] ?? '–']) }}">{{ __('ersetzt') }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_UNCOLLECTIBLE)
<span class="badge">{{ __('abgeschrieben') }}</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>