From cfbe339df0940492995dbb343d3cb86d64c4daf0 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 02:51:35 +0200 Subject: [PATCH] Die Seitenleiste fragt zuerst, ob etwas auf dich wartet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Livewire/Admin/Setup.php | 77 ++++++ app/Support/Navigation.php | 236 +++++++++++++----- lang/de/admin.php | 3 + lang/de/setup.php | 36 +++ lang/en/admin.php | 3 + lang/en/setup.php | 32 +++ .../views/components/shell/nav.blade.php | 56 ++++- .../views/components/ui/nav-item.blade.php | 10 + .../views/livewire/admin/setup.blade.php | 36 +++ routes/admin.php | 4 + tests/Feature/Admin/ConsoleNavigationTest.php | 180 ++++++++++++- tests/Feature/Admin/PlanAdminTest.php | 8 +- 12 files changed, 608 insertions(+), 73 deletions(-) create mode 100644 app/Livewire/Admin/Setup.php create mode 100644 lang/de/setup.php create mode 100644 lang/en/setup.php create mode 100644 resources/views/livewire/admin/setup.blade.php diff --git a/app/Livewire/Admin/Setup.php b/app/Livewire/Admin/Setup.php new file mode 100644 index 0000000..6d3649c --- /dev/null +++ b/app/Livewire/Admin/Setup.php @@ -0,0 +1,77 @@ + $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> + */ + 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); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 56a73c7..15d7b8e 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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}> */ + /** + * 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}> + */ 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 + */ + 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> + */ + 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; } } diff --git a/lang/de/admin.php b/lang/de/admin.php index ef6d14e..52adfa2 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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', diff --git a/lang/de/setup.php b/lang/de/setup.php new file mode 100644 index 0000000..80bec24 --- /dev/null +++ b/lang/de/setup.php @@ -0,0 +1,36 @@ + '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.', + ], +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index d862065..b7f0c4b 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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', diff --git a/lang/en/setup.php b/lang/en/setup.php new file mode 100644 index 0000000..0b88241 --- /dev/null +++ b/lang/en/setup.php @@ -0,0 +1,32 @@ + '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 installation’s 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.', + ], +]; diff --git a/resources/views/components/shell/nav.blade.php b/resources/views/components/shell/nav.blade.php index d4d164b..9f07195 100644 --- a/resources/views/components/shell/nav.blade.php +++ b/resources/views/components/shell/nav.blade.php @@ -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 @@

{{ $group['label'] }}

@endif @endforeach + {{-- 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) -

{{ $footer }}

+ @php $updateWaiting = ($counts['update'] ?? 0) > 0; @endphp + @if ($console) + $updateWaiting, + 'text-muted hover:text-ink hover:bg-surface-hover' => ! $updateWaiting, + ])> + {{ $footer }} + @if ($updateWaiting) + {{ __('admin.nav_update_waiting') }} + @endif + + @else +

{{ $footer }}

+ @endif @endif diff --git a/resources/views/components/ui/nav-item.blade.php b/resources/views/components/ui/nav-item.blade.php index aa4beb8..b675d63 100644 --- a/resources/views/components/ui/nav-item.blade.php +++ b/resources/views/components/ui/nav-item.blade.php @@ -28,10 +28,20 @@ merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}> @isset($icon){{ $icon }}@endisset {{ $slot }} + {{-- 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){{ $badge }}@endisset @else merge(['class' => $base.' '.$state]) }}> @isset($icon){{ $icon }}@endisset {{ $slot }} + {{-- 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){{ $badge }}@endisset @endif diff --git a/resources/views/livewire/admin/setup.blade.php b/resources/views/livewire/admin/setup.blade.php new file mode 100644 index 0000000..c0a18fa --- /dev/null +++ b/resources/views/livewire/admin/setup.blade.php @@ -0,0 +1,36 @@ +
+
+

{{ __('setup.eyebrow') }}

+

+ {{ __('setup.title') }} +

+

{{ __('setup.subtitle') }}

+
+ + @foreach ($groups as $gruppe => $kacheln) +
+

{{ __('setup.group.'.$gruppe) }}

+

{{ __('setup.group_hint.'.$gruppe) }}

+ + {{-- 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. --}} + +
+ @endforeach +
diff --git a/routes/admin.php b/routes/admin.php index bc7ee96..0ffb7dc 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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 diff --git a/tests/Feature/Admin/ConsoleNavigationTest.php b/tests/Feature/Admin/ConsoleNavigationTest.php index 121d97e..c2f5f8e 100644 --- a/tests/Feature/Admin/ConsoleNavigationTest.php +++ b/tests/Feature/Admin/ConsoleNavigationTest.php @@ -1,8 +1,11 @@ */ 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'); +}); + +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')); +}); diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 26a3ea0..0c5568e 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -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')); });