Die Seitenleiste fragt zuerst, ob etwas auf dich wartet

Achtundzwanzig Eintraege in sieben Gruppen, und „System" war der Platz fuer
alles, was sonst nirgends hinpasste: Mail-Einrichtung neben einem
Rechtsdokument, neben den persoenlichen Kontoeinstellungen, neben der
Mitarbeiterverwaltung — und ganz unten die Seite, die sagt, was liegt. Der
Betreiber hat es so beschrieben: „offene Punkte ist der letzte Punkt, dann
Rolle drueber und Einstellungen wieder drueber".

Zwei Regeln ordnen es jetzt.

Was man einmal einrichtet, verlaesst die Leiste: neun Seiten liegen als
Kacheln hinter EINEM Eintrag (admin.setup), gruppiert nach dem, was sie
einrichten. Keine dieser Seiten wurde angefasst — sie behalten Route,
Berechtigung und Inhalt, es aendert sich nur der Weg dorthin. Damit ist der
Umbau rueckholbar.

Und die drei Seiten, auf denen etwas WARTET, stehen ganz oben, mit einer Zahl
daneben: das ist die Frage, mit der man eine Konsole oeffnet. Stoerungen sind
aus „Betrieb" nach oben gezogen und Zahlungsprobleme aus „Geld" — umgezogen,
nicht verdoppelt. Bei null faellt die Plakette weg, der Eintrag bleibt: eine
Seite, die verschwindet, sobald nichts offen ist, ist genau dann nicht
erreichbar, wenn man nachsehen will, ob wirklich nichts offen ist.

Zwanzig Eintraege statt achtundzwanzig, jede Seite genau einmal.

Zwei Dinge, die beim Verschieben kaputtgegangen waeren:

  * Die neun verschobenen Seiten standen nicht mehr in console(). Damit war auf
    ihnen KEIN Eintrag markiert (Codex R15, P2) und currentLabel() lieferte
    null — die Brotkrume haette dort nur noch „Konsole" gesagt. Zwei Stellen,
    eine Wurzel: die Kachelliste ist nach Navigation::setup() gewandert, wo
    beide sie lesen, und isCurrent() haelt die Tuer markiert, solange man
    dahinter steht.
  * Die Versionszeile im Fuss stand als toter Text da, waehrend die Seite mit
    dem Aktualisierungsknopf in die Einrichtung gezogen war. Gemeldet vom
    Betreiber: „man sieht es nicht, ohne genau hinzuklicken." Sie ist jetzt der
    Weg dorthin — und sagt in der Akzentfarbe, wenn etwas wartet. Wartet
    nichts, bleibt sie grau: eine Zeile, die immer ruft, ruft nie.

Die drei Zahlen liegen fuer eine Minute im Zwischenspeicher. Diese Leiste
rendert auf JEDER Konsolenseite; ohne das waeren es vier Abfragen je
Seitenaufruf — eine Abgabe, die man erst sucht, wenn die Konsole zaeh ist.

Achtzehn Zusicherungen, darunter die, auf die es ankommt: keine der
achtundzwanzig Seiten von vorher ist verlorengegangen. Die Liste steht im Test
ausgeschrieben und nicht aus der Repository-Geschichte gelesen — ein Test, der
sich seine Erwartung aus demselben Repository holt, das er prueft, prueft
nichts.

Entwurf: docs/superpowers/specs/2026-08-04-konsolen-seitenleiste-design.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 02:51:35 +02:00
parent d04a76b6ee
commit cfbe339df0
12 changed files with 608 additions and 73 deletions

View File

@ -0,0 +1,77 @@
<?php
namespace App\Livewire\Admin;
use App\Support\Navigation;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Die Tür zu allem, was man einmal einrichtet.
*
* Neun Seiten standen in der Seitenleiste zwischen dem Tagesgeschäft die
* Mail-Einrichtung neben einem Rechtsdokument, neben den persönlichen
* Kontoeinstellungen, neben der Mitarbeiterverwaltung. Sie sind gleich wichtig
* und ungleich häufig: man fasst sie an, wenn etwas Neues dazukommt, und danach
* monatelang nicht mehr. In einer Liste, die man jeden Tag liest, kosten sie
* jeden Tag Aufmerksamkeit.
*
* Diese Seite legt sie hinter EINEN Eintrag und ordnet sie nach dem, was sie
* einrichten. Sie fasst nichts an: jede Seite behält ihre Route, ihre
* Berechtigung und ihren Inhalt es ändert sich nur der Weg dorthin. Damit ist
* der Umbau rückholbar, und ob einzelne dieser Seiten später verschmelzen, ist
* eine eigene Entscheidung.
*
* Bewusst Kacheln und keine Reiterleiste: neun Reiter wären wieder das, was
* hier gerade abgeschafft wird.
*/
#[Layout('layouts.admin')]
class Setup extends Component
{
public function render()
{
return view('livewire.admin.setup', [
'groups' => $this->visibleGroups(),
])->title(__('setup.title'));
}
/**
* Nur, was dieser Betreiber auch öffnen darf und keine leere Gruppe.
*
* Eine Kachel, die zu einer 403 führt, ist schlimmer als eine fehlende:
* sie sieht aus wie ein Weg. Dieselbe Prüfung, die x-shell.nav je Eintrag
* anwendet, damit beide Wege zu einer Seite dieselbe Antwort geben.
*
* @return array<string, array<int, array{0:string,1:string,2:string,3:mixed}>>
*/
private function visibleGroups(): array
{
$sichtbar = [];
foreach (Navigation::setup() as $gruppe => $kacheln) {
$erlaubt = array_values(array_filter(
$kacheln,
fn (array $kachel) => $this->mayOpen($kachel[3]),
));
if ($erlaubt !== []) {
$sichtbar[$gruppe] = $erlaubt;
}
}
return $sichtbar;
}
private function mayOpen(mixed $capability): bool
{
if ($capability === null) {
return true;
}
$betreiber = auth('operator')->user();
return is_array($capability)
? collect($capability)->contains(fn (string $eine) => (bool) $betreiber?->can($eine))
: (bool) $betreiber?->can($capability);
}
}

View File

@ -2,6 +2,12 @@
namespace App\Support;
use App\Models\DunningCase;
use App\Models\FailedCheckout;
use App\Models\Incident;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\Cache;
/**
* The navigation of both shells, in one place.
*
@ -41,19 +47,52 @@ final class Navigation
];
}
/** @return array<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:?string}>}> */
/**
* Die Konsole, geordnet nach dem, was man mit ihr tut.
*
* Es waren achtundzwanzig Eintraege in sieben Gruppen, und „System" war der
* Platz fuer alles, was sonst nirgends hinpasste: Mail-Einrichtung neben
* einem Rechtsdokument, neben den persoenlichen Kontoeinstellungen, neben
* der Mitarbeiterverwaltung und ganz unten die Seite, die sagt, was
* liegt. Der Betreiber hat es so beschrieben: „offene Punkte ist der letzte
* Punkt, dann Rolle drueber und Einstellungen wieder drueber".
*
* Zwei Regeln ordnen es jetzt. Was man einmal einrichtet, verlaesst die
* Leiste in den Einrichtungsbereich (admin.setup). Und die drei Seiten, auf
* denen etwas auf jemanden WARTET, stehen ganz oben, mit einer Zahl daneben
* das ist die Frage, mit der man eine Konsole oeffnet.
*
* Jede Seite steht genau EINMAL. Stoerungen sind aus „Betrieb" nach oben
* gezogen und Zahlungsprobleme aus „Geld"; sie sind dort nicht zusaetzlich
* geblieben. Eine Seite an zwei Stellen ist ihre eigene Verwirrung.
*
* Ein Eintrag ist [Route, Symbol, Uebersetzungsschluessel, Berechtigung],
* optional gefolgt vom Schluessel, unter dem attentionCounts() seine Zahl
* fuehrt.
*
* @return array<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:mixed,4?:string}>}>
*/
public static function console(): array
{
return [
// Allein und ohne Überschrift: die Seite, auf der man landet.
// Allein und ohne Ueberschrift: die Seite, auf der man landet.
['label' => null, 'items' => [
['admin.overview', 'gauge', 'overview', null],
]],
// Wo etwas auf jemanden wartet. Der einzige Block, der eine Zahl
// traegt — und der einzige, der nach Dringlichkeit sortiert ist
// statt nach Gegenstand.
['label' => __('admin.nav_group.attention'), 'items' => [
// Ohne Berechtigung: wer die Konsole oeffnen darf, soll wissen,
// worauf er sich verlassen kann und worauf nicht.
['admin.open-work', 'alert-triangle', 'open_work', null, 'open_work'],
['admin.incidents', 'bell', 'incidents', null, 'incidents'],
['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage', 'payment_problems'],
]],
// Wer bei uns ist und was er hat.
['label' => __('admin.nav_group.customers'), 'items' => [
['admin.customers', 'users', 'customers', null],
['admin.instances', 'box', 'instances', null],
['admin.plans', 'tag', 'plans', 'plans.manage'],
]],
// Die Maschinen und ihre Adressen — der Bestand, den man ansieht.
['label' => __('admin.nav_group.fleet'), 'items' => [
@ -63,86 +102,149 @@ final class Navigation
// Hostnamen und ihre Zertifikate: was hier steht, entscheidet,
// ob eine Adresse ANTWORTET. Kein Einrichten, sondern Bestand.
['admin.proxy-hosts', 'globe', 'proxy_hosts', 'site.manage'],
// Stand vorher unter System, mit der Begründung, der Tunnel
// müsse stehen, bevor irgendetwas geht. Das stimmt — gilt aber
// genauso für Integrationen und Bereitschaft, die dort bleiben.
// Der Unterschied: das hier ist eine LISTE VON GEGENSTELLEN,
// die man beim Arbeiten ansieht, so wie die Hostliste. Dieselbe
// Begründung, mit der Hostnamen schon aus System herauskamen.
// Eine LISTE VON GEGENSTELLEN, die man beim Arbeiten ansieht,
// so wie die Hostliste — nicht etwas, das man einrichtet.
['admin.vpn', 'shield', 'vpn', null],
]],
// Was gerade läuft oder klemmt. Vorgänge, kein Bestand.
//
// Diese Gruppe trug bis hierher zwölf Einträge — Maschinen, Geld und
// Post unter einem Wort, das auf alle drei zutrifft und deshalb
// keinem hilft. Sie heißt weiter „Betrieb" und meint jetzt nur noch
// das eine.
// Was gerade laeuft. Vorgaenge, kein Bestand. Stoerungen standen
// hier und stehen jetzt oben: sie sind kein Vorgang, den man
// verfolgt, sondern einer, der jemanden braucht.
['label' => __('admin.nav_group.operations'), 'items' => [
['admin.provisioning', 'activity', 'provisioning', null],
['admin.maintenance', 'alert-triangle', 'maintenance', null],
['admin.incidents', 'bell', 'incidents', null],
// Läuft, statt eingerichtet zu werden — deshalb hier und nicht
// neben admin.mail unter System.
// Laeuft, statt eingerichtet zu werden — deshalb hier und nicht
// im Einrichtungsbereich neben admin.mail.
['admin.mail-pace', 'gauge', 'mail_pace', 'mail.manage'],
]],
// Alles, wo Geld drinsteht.
['label' => __('admin.nav_group.billing'), 'items' => [
['admin.revenue', 'trending-up', 'revenue', null],
['admin.invoices', 'file-text', 'invoices', 'site.manage'],
['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage'],
// Eigener Eintrag, kein Abschnitt der Einstellungen: was hier
// gesetzt wird, steht auf einem Rechtsdokument, und neben dem
// Sichtbarkeitsschalter ändert das jemand im Vorbeigehen.
// Sichtbarkeitsschalter aendert das jemand im Vorbeigehen.
['admin.finance', 'receipt', 'finance', 'site.manage'],
]],
// Was geschrieben wird — gelesen beim Antworten, nicht beim
// Einrichten.
// Was geschrieben wird — gelesen beim Antworten. Die Vorlagen und
// ihre Vorschau sind dagegen etwas, das man einmal schreibt: sie
// stehen im Einrichtungsbereich.
['label' => __('admin.nav_group.post'), 'items' => [
['admin.inbox', 'mail', 'inbox', 'customers.manage'],
['admin.mail-log', 'send', 'mail_log', 'customers.manage'],
['admin.templates', 'file-text', 'templates', 'customers.manage'],
// Stand vorher unter System, neben den Postfächern. Sie zeigt
// aber VORLAGEN, keine Postfächer — und gehört deshalb neben
// die Vorlagen, die sie darstellt.
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
]],
// Was einmal eingerichtet wird und danach gilt.
['label' => __('admin.nav_group.system'), 'items' => [
// Die Postfächer und der Server, über den Mail dieses Haus
// verlässt. Einrichtung, anders als die vier Seiten unter Post.
['admin.mail', 'mail', 'mail', 'mail.manage'],
// Merged from the former admin.secrets + admin.infrastructure
// pages, which split the same subject by storage mechanism
// instead of by what it configures. Reachable with EITHER
// capability — see App\Livewire\Admin\Integrations — so the
// entry stays visible to exactly who could reach at least one
// of the two before: an array here means "any of these",
// never "all of these" (contrast a single string elsewhere in
// this file, which x-shell.nav checks with plain ->can()).
['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']],
// Every check App\Support\Readiness knows about, gathered on
// one page — same gate as Integrations just above, because
// both halves it configures come back together here.
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
// Der AV-Vertrag: gilt für die ganze Installation, nicht für
// einen Kunden.
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
// Two-factor enrolment used to sit here as an entry of its
// own. It is a tab of Settings now — it is a setting, and it
// was the one place an operator had to leave the settings page
// to change something about their own access. The standalone
// page stays reachable at its route, because that is where
// RequireOperatorTwoFactor sends somebody who may not open
// anything else yet, Settings included.
['admin.settings', 'settings', 'settings', null],
['admin.roles', 'users', 'roles', 'staff.manage'],
// Ohne Berechtigung: wer die Konsole öffnen darf, soll wissen,
// worauf er sich verlassen kann und worauf nicht.
['admin.open-work', 'alert-triangle', 'open_work', null],
// Ohne Ueberschrift und ganz unten: eine Tuer, keine Gruppe.
['label' => null, 'items' => [
['admin.setup', 'settings', 'setup', null],
]],
];
}
/**
* Die Zahlen fuer den Block „Braucht dich".
*
* Diese Leiste rendert auf JEDER Konsolenseite. Ohne Zwischenspeicher
* waeren das zwei Abfragen je Seitenaufruf eine Abgabe, die man spaeter
* sucht, wenn die Seiten langsam werden. Eine Minute ist kurz genug, dass
* niemand eine geloeste Stoerung noch lange gezaehlt sieht, und lang genug,
* dass ein Klick durch fuenf Seiten die Datenbank einmal fragt statt zehnmal.
*
* „Offene Punkte" kostet ohnehin nichts: das Register liegt im Code.
*
* @return array<string, int>
*/
public static function attentionCounts(): array
{
return Cache::remember('nav.attention', now()->addMinute(), fn () => [
'open_work' => count(OpenWork::all()),
'incidents' => Incident::query()->whereNull('resolved_at')->count(),
// Beides, weil die Seite beides zeigt: eine offene Mahnung und ein
// gescheiterter Bezahlvorgang sind zwei Wege zu demselben Problem.
'payment_problems' => DunningCase::query()->whereNull('settled_at')->count()
+ FailedCheckout::query()->whereNull('resolved_at')->count(),
// Die Aktualisierung, obwohl sie in keinem Eintrag der Leiste
// steht: sie steht im FUSS, neben der laufenden Version.
//
// Der Betreiber hat es so gemeldet: „Einstellungen ist in der
// Einrichtung, wegen den Versionsupdates — man sieht es nicht,
// ohne genau hinzuklicken." Die Seite dorthin zu verschieben war
// richtig (ihr Konto-Teil gehoert dahin), die Folge nicht: eine
// verfuegbare Aktualisierung ist nichts, wonach man sucht.
//
// Hier statt in einem eigenen Eintrag, weil die Version im Fuss
// ohnehin schon steht. Ein vierter Eintrag unter „Braucht dich"
// haette dieselbe Auskunft an einer zweiten Stelle wiederholt.
'update' => app(UpdateChannel::class)->state()['available'] ? 1 : 0,
]);
}
/**
* Der Einrichtungsbereich: die neun Seiten, die man einmal einrichtet.
*
* Sie stehen hier und nicht im Livewire-Bauteil, das sie zeichnet aus
* demselben Grund, aus dem die Seitenleiste hier steht: es ist Navigation,
* und wer sie liest, sind drei (die Kachelseite, die Markierung „du bist
* hier" und die Brotkrume). Drei Leser, eine Liste.
*
* Dieselbe Form wie ein Eintrag der Leiste, und dieselbe Regel: ein Array
* bei der Berechtigung heisst „eine davon genuegt", nie „alle".
*
* @return array<string, array<int, array{0:string,1:string,2:string,3:mixed}>>
*/
public static function setup(): array
{
return [
// Womit und woraus dieses Haus schreibt.
'delivery' => [
['admin.mail', 'mail', 'mail', 'mail.manage'],
['admin.templates', 'file-text', 'templates', 'customers.manage'],
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
],
// Was verkauft wird.
'offer' => [
['admin.plans', 'tag', 'plans', 'plans.manage'],
],
// Wer hereindarf und womit dieses Haus nach aussen spricht.
'access' => [
['admin.roles', 'users', 'roles', 'staff.manage'],
['admin.settings', 'settings', 'settings', null],
['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']],
],
// Der Zustand des Hauses selbst.
'house' => [
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
],
];
}
/**
* Ist dieser Eintrag der, auf dem man gerade steht?
*
* Fuer alles ausser dem Einrichtungsbereich ist das die Route selbst. Der
* Einrichtungsbereich bleibt zusaetzlich markiert, solange man auf einer
* der Seiten steht, die er sammelt sonst verliert die Leiste auf allen
* neun verschobenen Seiten ihr „du bist hier", und man steht in einer
* Konsole, die nicht mehr sagt, wo man ist. (Codex R15, P2 am Umbau.)
*/
public static function isCurrent(string $route, bool $console = false): bool
{
if ($console ? AdminArea::routeIs($route) : request()->routeIs($route)) {
return true;
}
if ($console && $route === 'admin.setup') {
foreach (self::setup() as $kacheln) {
foreach ($kacheln as [$ziel]) {
if (AdminArea::routeIs($ziel)) {
return true;
}
}
}
}
return false;
}
/**
* The label of the entry the current request belongs to.
*
@ -167,6 +269,20 @@ final class Navigation
}
}
// Die neun verschobenen Seiten stehen nicht mehr in der Leiste. Ohne
// diesen zweiten Blick verloeren sie ihre Brotkrume und die Konsole
// sagte auf ihnen nur noch „Konsole" — derselbe Schaden wie die
// fehlende Markierung, aus derselben Ursache.
if ($console) {
foreach (self::setup() as $kacheln) {
foreach ($kacheln as [$route, , $key]) {
if (AdminArea::routeIs($route)) {
return __($prefix.$key);
}
}
}
}
return null;
}
}

View File

@ -5,7 +5,9 @@ return [
'badge' => 'Admin',
'to_portal' => 'Zum Kundenportal',
'nav_update_waiting' => 'Aktualisierung verfügbar',
'nav_group' => [
'attention' => 'Braucht dich',
'customers' => 'Kunden',
'fleet' => 'Flotte',
'operations' => 'Betrieb',
@ -15,6 +17,7 @@ return [
],
'nav' => [
'setup' => 'Einrichtung',
'proxy_hosts' => 'Hostnamen',
'dpa' => 'AV-Vertrag',
'mail_preview' => 'E-Mail-Vorschau',

36
lang/de/setup.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// Der Einrichtungsbereich: eine Tür für die neun Seiten, die man einmal
// einrichtet und danach kaum wieder anfasst. Eigene Datei, weil es eine eigene
// Seite ist — die Beschriftungen der Kacheln kommen aus admin.nav.*, damit ein
// Eintrag nicht an zwei Stellen zwei Namen hat.
return [
'eyebrow' => 'Konsole',
'title' => 'Einrichtung',
'subtitle' => 'Was einmal eingerichtet wird und danach gilt. Diese Seiten standen bis hierher zwischen dem Tagesgeschäft in der Seitenleiste — sie sind gleich wichtig und ungleich häufig.',
'group' => [
'delivery' => 'Zustellung',
'offer' => 'Angebot',
'access' => 'Zugang',
'house' => 'Das Haus',
],
'group_hint' => [
'delivery' => 'Womit und woraus dieses Haus schreibt.',
'offer' => 'Was verkauft wird — Familien, Versionen, Preise.',
'access' => 'Wer hereindarf, und womit dieses Haus nach außen spricht.',
'house' => 'Der Zustand der Installation selbst.',
],
'tile' => [
'mail' => 'Der Server, die Postfächer und die Frage, welche Mailart aus welchem verschickt wird.',
'templates' => 'Die Texte, die dieses Haus verschickt.',
'mail_preview' => 'Eine Mail ansehen, bevor ein Kunde sie sieht.',
'plans' => 'Der Katalog: Paketfamilien, Versionen, Preise und Zusatzmodule.',
'roles' => 'Wer die Konsole öffnen darf und was er darin tun kann.',
'settings' => 'Das eigene Konto, Zwei-Faktor, und die Version dieser Installation.',
'integrations' => 'Stripe, DNS, die Überwachung — und die Datei, die unter allem liegt.',
'readiness' => 'Jede Prüfung, die diese Installation über sich selbst kennt.',
'dpa' => 'Der Auftragsverarbeitungsvertrag, der für die ganze Installation gilt.',
],
];

View File

@ -5,7 +5,9 @@ return [
'badge' => 'Admin',
'to_portal' => 'To customer portal',
'nav_update_waiting' => 'Update available',
'nav_group' => [
'attention' => 'Needs you',
'customers' => 'Customers',
'fleet' => 'Fleet',
'operations' => 'Operations',
@ -15,6 +17,7 @@ return [
],
'nav' => [
'setup' => 'Setup',
'proxy_hosts' => 'Hostnames',
'dpa' => 'Processing agreement',
'mail_preview' => 'Mail preview',

32
lang/en/setup.php Normal file
View File

@ -0,0 +1,32 @@
<?php
return [
'eyebrow' => 'Console',
'title' => 'Setup',
'subtitle' => 'What is configured once and then holds. These pages used to sit among the daily work in the sidebar — equally important, and nowhere near as often needed.',
'group' => [
'delivery' => 'Delivery',
'offer' => 'Offering',
'access' => 'Access',
'house' => 'The installation',
],
'group_hint' => [
'delivery' => 'What this house writes with, and what it writes from.',
'offer' => 'What is sold — families, versions, prices.',
'access' => 'Who may come in, and what this house speaks to outside.',
'house' => 'The state of the installation itself.',
],
'tile' => [
'mail' => 'The server, the mailboxes, and which kind of mail leaves from which.',
'templates' => 'The texts this house sends.',
'mail_preview' => 'Look at a mail before a customer does.',
'plans' => 'The catalogue: plan families, versions, prices and add-ons.',
'roles' => 'Who may open the console, and what they may do in it.',
'settings' => 'Your own account, two-factor, and this installations version.',
'integrations' => 'Stripe, DNS, monitoring — and the file underneath all of it.',
'readiness' => 'Every check this installation knows about itself.',
'dpa' => 'The data processing agreement that applies to the whole installation.',
],
];

View File

@ -8,6 +8,13 @@
'console' => false,
'footer' => null,
])
@php
// Die Zahlen fuer den Block „Braucht dich". Einmal je Seitenaufruf geholt,
// nicht je Eintrag — und dahinter ein Zwischenspeicher, weil diese Leiste
// auf JEDER Konsolenseite rendert. Das Portal hat keine, dort bleibt es
// ein leeres Feld ohne Abfrage.
$counts = $console ? \App\Support\Navigation::attentionCounts() : [];
@endphp
{{--
The sidebar, shared by both shells.
@ -77,22 +84,65 @@
<p class="px-3 pb-2 pt-5 lbl">{{ $group['label'] }}</p>
@endif
<nav class="space-y-0.5">
@foreach ($visible as [$route, $icon, $key, $capability])
@foreach ($visible as $item)
@php
[$route, $icon, $key] = $item;
// Fuenftes Feld, optional: der Schluessel, unter dem
// Navigation::attentionCounts() die Zahl fuehrt. Als
// Index gelesen statt destrukturiert — das Portal hat
// vier Felder je Eintrag, und eine Destrukturierung auf
// fuenf warnte dort bei jedem einzelnen.
$count = isset($item[4]) ? ($counts[$item[4]] ?? 0) : 0;
@endphp
<x-ui.nav-item
:href="route($route)"
:active="$console ? \App\Support\AdminArea::routeIs($route) : request()->routeIs($route)"
:active="\App\Support\Navigation::isCurrent($route, $console)"
@click="nav = false"
>
<x-slot:icon><x-ui.icon :name="$icon" class="size-[1.15rem]" /></x-slot:icon>
{{ __($prefix.'.'.$key) }}
{{-- Bei null KEINE Plakette, der Eintrag bleibt aber
stehen: eine Seite, die verschwindet, sobald nichts
offen ist, ist genau dann nicht erreichbar, wenn man
nachsehen will, ob wirklich nichts offen ist. --}}
@if ($count > 0)
<x-slot:badge>
<span class="rounded-pill bg-accent px-2 py-0.5 text-xs font-semibold text-on-accent">{{ $count }}</span>
</x-slot:badge>
@endif
</x-ui.nav-item>
@endforeach
</nav>
@endforeach
</div>
{{-- Die laufende Version und, wenn es eine neuere gibt, dass es sie gibt.
Sie stand hier schon, aber als toter Text: die Seite, auf der man
etwas damit tun kann, liegt seit dem Umbau der Leiste im
Einrichtungsbereich. Der Betreiber hat genau das gemeldet „man sieht
es nicht, ohne genau hinzuklicken".
Also bleibt die Zeile, wo sie war, und wird zum Weg dorthin. Ist etwas
verfuegbar, sagt sie es in der Akzentfarbe statt in Grau; ist nichts
verfuegbar, bleibt sie so leise wie vorher. Eine Zeile, die immer
ruft, ruft nie. --}}
@if ($footer)
<p class="shrink-0 px-3 pt-6 font-mono text-[11px] leading-relaxed text-muted">{{ $footer }}</p>
@php $updateWaiting = ($counts['update'] ?? 0) > 0; @endphp
@if ($console)
<a href="{{ route('admin.settings') }}?tab=installation"
@class([
'shrink-0 rounded px-3 py-2 mt-6 block font-mono text-[11px] leading-relaxed transition-colors',
'bg-accent-subtle text-accent-text hover:bg-accent-subtle/70' => $updateWaiting,
'text-muted hover:text-ink hover:bg-surface-hover' => ! $updateWaiting,
])>
{{ $footer }}
@if ($updateWaiting)
<span class="mt-0.5 block font-sans font-semibold">{{ __('admin.nav_update_waiting') }}</span>
@endif
</a>
@else
<p class="shrink-0 px-3 pt-6 font-mono text-[11px] leading-relaxed text-muted">{{ $footer }}</p>
@endif
@endif
</aside>

View File

@ -28,10 +28,20 @@
<span aria-disabled="true" {{ $attributes->merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}>
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
<span class="{{ $label }}">{{ $slot }}</span>
{{-- Rechtsbuendig, per ms-auto statt justify-between: der Abstand
zwischen Symbol und Text bleibt damit derselbe wie bei jedem
Eintrag ohne Plakette. shrink-0, damit eine zweistellige Zahl den
Text nicht umbricht (R18: ein Eintrag ist einzeilig). --}}
@isset($badge)<span class="ms-auto shrink-0">{{ $badge }}</span>@endisset
</span>
@else
<a href="{{ $href }}" @if ($active) aria-current="page" @endif {{ $attributes->merge(['class' => $base.' '.$state]) }}>
@isset($icon)<span class="shrink-0">{{ $icon }}</span>@endisset
<span class="{{ $label }}">{{ $slot }}</span>
{{-- Rechtsbuendig, per ms-auto statt justify-between: der Abstand
zwischen Symbol und Text bleibt damit derselbe wie bei jedem
Eintrag ohne Plakette. shrink-0, damit eine zweistellige Zahl den
Text nicht umbricht (R18: ein Eintrag ist einzeilig). --}}
@isset($badge)<span class="ms-auto shrink-0">{{ $badge }}</span>@endisset
</a>
@endif

View File

@ -0,0 +1,36 @@
<div class="mx-auto max-w-[1120px] space-y-8">
<header class="animate-rise">
<p class="lbl">{{ __('setup.eyebrow') }}</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ __('setup.title') }}
</h1>
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('setup.subtitle') }}</p>
</header>
@foreach ($groups as $gruppe => $kacheln)
<section class="animate-rise" style="animation-delay: {{ $loop->index * 30 + 20 }}ms">
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('setup.group.'.$gruppe) }}</h2>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('setup.group_hint.'.$gruppe) }}</p>
{{-- Kacheln statt einer Reiterleiste: neun Reiter waeren wieder
die Wurst, die hier gerade abgeschafft wird. Und Kacheln
tragen einen Satz darueber, WAS die Seite einrichtet in
einer Leiste steht nur ihr Name. --}}
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($kacheln as [$route, $icon, $key, $capability])
<a href="{{ route($route) }}" wire:key="setup-{{ $key }}"
class="group flex min-h-[5.5rem] gap-3.5 rounded-lg border border-line bg-surface p-4 shadow-xs transition-colors hover:border-line-strong hover:bg-surface-hover">
{{-- R18: Symbol neben dem Text, nie darueber, size-5. --}}
<span class="mt-0.5 grid size-9 shrink-0 place-items-center rounded-md bg-surface-2 text-muted group-hover:text-ink">
<x-ui.icon :name="$icon" class="size-5" />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-ink">{{ __('admin.nav.'.$key) }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">{{ __('setup.tile.'.$key) }}</span>
</span>
</a>
@endforeach
</div>
</section>
@endforeach
</div>

View File

@ -127,6 +127,10 @@ Route::get('/mail', Admin\Mail::class)->name('mail');
// eines weiteren Abschnitts auf admin/mail (siehe deren Kommentar oben), weil
// diese Seite den laufenden Betrieb beobachtet statt etwas einzurichten.
Route::get('/mail-pace', Admin\MailPace::class)->name('mail-pace');
// Die Tuer zu allem, was man einmal einrichtet — neun Seiten, die bis hierher
// zwischen dem Tagesgeschaeft in der Seitenleiste standen. Ohne eigene
// Berechtigung: die Seite zeigt nur Kacheln, und jede prueft ihre eigene.
Route::get('/setup', Admin\Setup::class)->name('setup');
Route::get('/integrations', Admin\Integrations::class)->name('integrations');
// The former admin.secrets and admin.infrastructure pages, merged into the
// one above — grouped by what each value configures, not by which of the two

View File

@ -1,8 +1,11 @@
<?php
use App\Livewire\Admin\Setup;
use App\Models\Operator;
use App\Support\Navigation;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
/**
* Die Seitenleiste der Konsole.
@ -14,8 +17,28 @@ use Illuminate\Support\Facades\Route;
* Was diese Tests festhalten, ist nicht die Ordnung selbst (die darf sich
* ändern), sondern dass beim Umsortieren nichts verlorengeht und keine leere
* Überschrift entsteht.
*
* Seit dem zweiten Umbau (2026-08-04) gilt das über ZWEI Orte: die Leiste und
* den Einrichtungsbereich. Neun Seiten, die man einmal einrichtet, sind
* dorthin gezogen die Zusicherung „nichts geht verloren" muss deshalb beide
* zusammen prüfen, sonst hiesse sie nur noch „nichts geht in der Leiste
* verloren", und genau dort ist ja etwas verschwunden.
*/
/** Jeder Routenname, den die Konsole irgendwo anbietet — Leiste UND Kacheln. */
function consoleRouteNames(): array
{
$namen = array_column(consoleItems(), 0);
foreach (Navigation::setup() as $kacheln) {
foreach ($kacheln as $kachel) {
$namen[] = $kachel[0];
}
}
return $namen;
}
/** @return list<array{0:string,1:string,2:string,3:mixed}> */
function consoleItems(): array
{
@ -41,10 +64,37 @@ it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', functi
// an der neuen Stelle eingefügt, an der alten vergessen.
expect($routes)->toHaveCount(count(array_unique($routes)));
// Die Zahl steht hier bewusst als Zahl: sinkt sie, ist beim Umsortieren
// ein Eintrag unter den Tisch gefallen, und genau das sähe niemand.
// 28 statt 27 seit admin.mail-pace (Versandtakt) dazukam.
expect($routes)->toHaveCount(28);
// Auch über beide Orte zusammen darf nichts doppelt stehen: eine Seite in
// der Leiste UND als Kachel wäre die Verwirrung, die dieser Umbau
// abschaffen soll.
$alle = consoleRouteNames();
expect($alle)->toHaveCount(count(array_unique($alle)));
});
it('laesst keine Seite fallen, die vor dem Umbau in der Leiste stand', function () {
// Die achtundzwanzig Einträge von vor dem 4.8.2026, ausgeschrieben statt
// aus der Geschichte gelesen: ein Test, der sich seine Erwartung aus
// demselben Repository holt, das er prüft, prüft nichts.
$vorher = [
'admin.overview', 'admin.customers', 'admin.instances', 'admin.plans',
'admin.hosts', 'admin.datacenters', 'admin.capacity', 'admin.proxy-hosts', 'admin.vpn',
'admin.provisioning', 'admin.maintenance', 'admin.incidents', 'admin.mail-pace',
'admin.revenue', 'admin.invoices', 'admin.payment-problems', 'admin.finance',
'admin.inbox', 'admin.mail-log', 'admin.templates', 'admin.mail.preview',
'admin.mail', 'admin.integrations', 'admin.readiness', 'admin.dpa',
'admin.settings', 'admin.roles', 'admin.open-work',
];
expect(array_values(array_diff($vorher, consoleRouteNames())))->toBe([]);
});
it('fuehrt keine Kachel, deren Route es nicht gibt', function () {
foreach (Navigation::setup() as $gruppe => $kacheln) {
foreach ($kacheln as [$route, , $key]) {
expect(Route::has($route))->toBeTrue("Route {$route} (Kachel {$key} in {$gruppe}) existiert nicht.");
expect(__('setup.tile.'.$key))->not->toBe('setup.tile.'.$key, "Kachel {$key} hat keinen Text.");
}
}
});
it('führt keinen Eintrag, dessen Route es nicht gibt', function () {
@ -78,10 +128,18 @@ it('stellt jede Gruppe unter eine Überschrift — außer der Übersicht', funct
->and($groups[0]['items'])->toHaveCount(1)
->and($groups[0]['items'][0][0])->toBe('admin.overview');
// Alle weiteren haben eine. Eine namenlose Gruppe mitten in der Leiste
// wäre wieder der Zustand, aus dem diese Arbeit kommt: sechs schwebende
// Einträge ohne Zugehörigkeit.
foreach (array_slice($groups, 1) as $group) {
// Die LETZTE trägt allein die Tür zum Einrichtungsbereich und braucht
// ebenfalls keine: eine Überschrift über einem einzigen Eintrag benennt
// nur ihn selbst noch einmal.
$letzte = $groups[count($groups) - 1];
expect($letzte['label'])->toBeNull()
->and($letzte['items'])->toHaveCount(1)
->and($letzte['items'][0][0])->toBe('admin.setup');
// Alle dazwischen haben eine. Eine namenlose Gruppe MITTEN in der Leiste
// wäre wieder der Zustand, aus dem diese Arbeit kommt: schwebende Einträge
// ohne Zugehörigkeit.
foreach (array_slice($groups, 1, -1) as $group) {
expect($group['label'])->not->toBeNull()->and($group['label'])->not->toBe('');
}
});
@ -124,3 +182,109 @@ it('zeigt die Gruppe, sobald der Betreiber einen ihrer Einträge sehen darf', fu
}
}
});
it('zaehlt die Zahlen neben den Eintraegen nur einmal je Minute', function () {
// Diese Leiste rendert auf JEDER Konsolenseite. Ohne Zwischenspeicher wären
// das drei Abfragen je Seitenaufruf — eine Abgabe, die man erst sucht, wenn
// die Konsole längst langsam ist.
Cache::forget('nav.attention');
$erste = Navigation::attentionCounts();
expect(Cache::has('nav.attention'))->toBeTrue()
->and(Navigation::attentionCounts())->toBe($erste);
});
it('zeigt keine Plakette, laesst den Eintrag aber stehen, wenn nichts offen ist', function () {
// Eine Seite, die verschwindet, sobald nichts offen ist, ist genau dann
// nicht erreichbar, wenn man nachsehen will, ob wirklich nichts offen ist.
Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0]);
$this->actingAs(admin(), 'operator');
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
expect($html)->toContain(navLabel(__('admin.nav_group.attention')))
->and($html)->toContain(__('admin.nav.incidents'));
});
it('nennt die Zahl, sobald etwas offen ist', function () {
Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 7, 'payment_problems' => 0]);
$this->actingAs(admin(), 'operator');
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
expect($html)->toContain('>7</span>');
});
it('zeigt im Einrichtungsbereich nur Kacheln, die dieser Betreiber oeffnen darf', function () {
// Eine Kachel, die zu einer 403 führt, ist schlimmer als eine fehlende:
// sie sieht aus wie ein Weg.
Livewire::actingAs(admin(), 'operator')
->test(Setup::class)
->assertSee(__('setup.group.delivery'))
->assertSee(__('setup.tile.plans'));
$html = Livewire::actingAs(Operator::factory()->role('Read-only')->create(), 'operator')
->test(Setup::class)
->html();
expect($html)->not->toContain(__('setup.tile.plans'));
});
it('haelt die Tuer zur Einrichtung markiert, solange man dahinter steht', function () {
// Codex R15, P2 am Umbau: die neun verschobenen Seiten stehen nicht mehr in
// der Leiste. Ohne diese Regel ist auf keiner von ihnen ein Eintrag
// markiert — die Konsole sagt dann nicht mehr, wo man ist.
$this->actingAs(admin(), 'operator');
foreach (Navigation::setup() as $kacheln) {
foreach ($kacheln as [$route, , $key]) {
$this->get(route($route));
expect(Navigation::isCurrent('admin.setup', console: true))
->toBeTrue("Auf {$key} ({$route}) ist die Einrichtung nicht markiert.");
}
}
});
it('nennt eine verschobene Seite weiter in der Brotkrume', function () {
// Derselbe Schaden aus derselben Ursache, nur an der anderen Stelle:
// currentLabel() sah nur in der Leiste nach und lieferte fuer die neun
// Seiten null — der Kopf haette dort nur noch „Konsole" gesagt.
$this->actingAs(admin(), 'operator');
$this->get(route('admin.plans'));
expect(Navigation::currentLabel(console: true))->toBe(__('admin.nav.plans'));
});
it('markiert die Einrichtung nicht, wenn man ganz woanders steht', function () {
// Die Kehrseite: eine Markierung, die immer leuchtet, ist keine.
$this->actingAs(admin(), 'operator');
$this->get(route('admin.customers'));
expect(Navigation::isCurrent('admin.setup', console: true))->toBeFalse();
});
it('macht die Versionszeile im Fuss zum Weg zur Aktualisierung', function () {
// Gemeldet vom Betreiber: „Einstellungen ist in der Einrichtung, wegen den
// Versionsupdates — man sieht es nicht, ohne genau hinzuklicken." Die
// Version stand im Fuss schon, aber als toter Text.
Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0, 'update' => 0]);
$this->actingAs(admin(), 'operator');
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
expect($html)->toContain(route('admin.settings').'?tab=installation')
// Solange nichts ansteht, bleibt sie leise: eine Zeile, die immer
// ruft, ruft nie.
->and($html)->not->toContain(__('admin.nav_update_waiting'));
});
it('sagt im Fuss, dass eine Aktualisierung wartet', function () {
Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0, 'update' => 1]);
$this->actingAs(admin(), 'operator');
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
expect($html)->toContain(__('admin.nav_update_waiting'));
});

View File

@ -510,9 +510,13 @@ it('closes the marketing modal to operators without the capability', function ()
});
it('shows the console entry only to those who may use it', function () {
$this->actingAs(owner(), 'operator')->get(route('admin.overview'))->assertSee(__('admin.nav.plans'));
// Der Katalog aendert sich selten und steht seit dem 4.8.2026 nicht mehr in
// der Seitenleiste, sondern als Kachel im Einrichtungsbereich. Die
// Zusicherung ist dieselbe geblieben — wer ihn nicht pflegen darf, sieht
// auch den Weg dorthin nicht.
$this->actingAs(owner(), 'operator')->get(route('admin.setup'))->assertSee(__('admin.nav.plans'));
$this->actingAs(Operator::factory()->role('Support')->create(), 'operator')
->get(route('admin.overview'))
->get(route('admin.setup'))
->assertDontSee(__('admin.nav.plans'));
});