diff --git a/app/Livewire/Admin/Invoices.php b/app/Livewire/Admin/Invoices.php
new file mode 100644
index 0000000..128f1d4
--- /dev/null
+++ b/app/Livewire/Admin/Invoices.php
@@ -0,0 +1,77 @@
+authorize('site.manage');
+ }
+
+ public function updatedSearch(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedYear(): void
+ {
+ $this->resetPage();
+ }
+
+ public function render()
+ {
+ $invoices = Invoice::query()
+ ->with(['customer', 'series'])
+ ->when($this->search !== '', function ($q) {
+ $term = '%'.$this->search.'%';
+ $q->where(fn ($w) => $w->where('number', 'like', $term)
+ ->orWhereHas('customer', fn ($c) => $c->where('name', 'like', $term)));
+ })
+ ->when($this->year !== '', fn ($q) => $q->whereYear('issued_on', (int) $this->year))
+ ->orderByDesc('issued_on')
+ ->orderByDesc('id')
+ ->paginate(25);
+
+ return view('livewire.admin.invoices', [
+ 'invoices' => $invoices,
+ // Straight from the rows rather than a fixed range: a list of years
+ // with nothing in them is a filter that mostly returns nothing.
+ //
+ // Derived in PHP rather than with YEAR() in raw SQL. That function
+ // is MySQL's; SQLite has no such thing, and a query that only runs
+ // on one engine turns the test database into a different product
+ // from the real one.
+ 'years' => Invoice::query()
+ ->orderByDesc('issued_on')
+ ->pluck('issued_on')
+ ->map(fn ($date) => $date?->format('Y'))
+ ->filter()
+ ->unique()
+ ->values(),
+ ]);
+ }
+}
diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php
index 396bc37..6c850e0 100644
--- a/app/Support/Navigation.php
+++ b/app/Support/Navigation.php
@@ -54,6 +54,7 @@ final class Navigation
// appears on a legal document, and burying it beside the
// site-visibility switch invites somebody to change one in passing.
['admin.finance', 'receipt', 'finance', 'site.manage'],
+ ['admin.invoices', 'file-text', 'invoices', 'site.manage'],
]],
['label' => __('admin.nav_group.system'), 'items' => [
['admin.mail', 'mail', 'mail', 'mail.manage'],
diff --git a/lang/de/admin.php b/lang/de/admin.php
index d65bce7..7f2cab7 100644
--- a/lang/de/admin.php
+++ b/lang/de/admin.php
@@ -21,6 +21,7 @@ return [
'maintenance' => 'Wartungen',
'vpn' => 'VPN',
'finance' => 'Finanzen',
+ 'invoices' => 'Rechnungen',
'revenue' => 'Umsatz',
'mail' => 'E-Mail',
'integrations' => 'Integrationen',
diff --git a/lang/de/invoices_admin.php b/lang/de/invoices_admin.php
new file mode 100644
index 0000000..d997d0f
--- /dev/null
+++ b/lang/de/invoices_admin.php
@@ -0,0 +1,18 @@
+ 'Rechnungen',
+ 'subtitle' => 'Jedes ausgestellte Dokument. Ausgestellte Rechnungen lassen sich nicht ändern — eine falsche wird durch Storno und Neuausstellung berichtigt.',
+ 'search' => 'Suche',
+ 'search_hint' => 'Rechnungsnummer oder Kunde',
+ 'year' => 'Jahr',
+ 'all_years' => 'Alle',
+ 'empty' => 'Noch keine Rechnung ausgestellt.',
+ 'col_number' => 'Nummer',
+ 'col_customer' => 'Kunde',
+ 'col_date' => 'Datum',
+ 'col_net' => 'Netto',
+ 'col_gross' => 'Brutto',
+ 'col_actions' => 'Aktionen',
+ 'download' => 'PDF',
+];
diff --git a/lang/en/admin.php b/lang/en/admin.php
index 9a0dfd2..5f9fb24 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -21,6 +21,7 @@ return [
'maintenance' => 'Maintenance',
'vpn' => 'VPN',
'finance' => 'Finance',
+ 'invoices' => 'Invoices',
'revenue' => 'Revenue',
'mail' => 'Email',
'integrations' => 'Integrations',
diff --git a/lang/en/invoices_admin.php b/lang/en/invoices_admin.php
new file mode 100644
index 0000000..9098cac
--- /dev/null
+++ b/lang/en/invoices_admin.php
@@ -0,0 +1,18 @@
+ 'Invoices',
+ 'subtitle' => 'Every document issued. An issued invoice cannot be edited — a wrong one is corrected by cancelling it and issuing another.',
+ 'search' => 'Search',
+ 'search_hint' => 'Invoice number or customer',
+ 'year' => 'Year',
+ 'all_years' => 'All',
+ 'empty' => 'No invoice issued yet.',
+ 'col_number' => 'Number',
+ 'col_customer' => 'Customer',
+ 'col_date' => 'Date',
+ 'col_net' => 'Net',
+ 'col_gross' => 'Gross',
+ 'col_actions' => 'Actions',
+ 'download' => 'PDF',
+];
diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php
index 3113248..e69ec28 100644
--- a/resources/views/components/ui/icon.blade.php
+++ b/resources/views/components/ui/icon.blade.php
@@ -6,6 +6,7 @@
'cloud' => '
{{ __('invoices_admin.subtitle') }}
+{{ __('invoices_admin.empty') }}
+ @else +| {{ __('invoices_admin.col_number') }} | +{{ __('invoices_admin.col_customer') }} | +{{ __('invoices_admin.col_date') }} | +{{ __('invoices_admin.col_net') }} | +{{ __('invoices_admin.col_gross') }} | +{{ __('invoices_admin.col_actions') }} | +
|---|---|---|---|---|---|
| {{ $invoice->number }} | +{{ $invoice->customer?->name ?? '—' }} | +{{ $invoice->issued_on?->local()->format('d.m.Y') }} | +{{ \App\Services\Billing\InvoiceMath::money($invoice->net_cents) }} | +{{ \App\Services\Billing\InvoiceMath::money($invoice->gross_cents) }} {{ $invoice->currency }} | +
+ {{-- A link, not a Livewire action: a PDF is a file
+ download, and routing one through a component
+ round-trip only adds a way for it to fail. --}}
+
+ |
+