diff --git a/VERSION b/VERSION index d2c582a..55a4dd6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.32 +1.3.33 diff --git a/app/Listeners/RecordSentMail.php b/app/Listeners/RecordSentMail.php new file mode 100644 index 0000000..ce925cc --- /dev/null +++ b/app/Listeners/RecordSentMail.php @@ -0,0 +1,89 @@ +record($event); + } catch (Throwable $e) { + // A register that cannot be written must never be the reason a mail + // fails. The send already happened by the time this runs, and + // throwing here would turn a delivered message into a failed job + // that gets delivered a second time on retry. + Log::error('Could not record a sent mail', ['exception' => $e]); + } + } + + private function record(MessageSent $event): void + { + $mailable = $event->data['__laravel_mailable'] ?? null; + + if ($mailable !== null && in_array($mailable, self::IGNORED, true)) { + return; + } + + $message = $event->message; + $to = collect($message->getTo())->map(fn ($address) => $address->getAddress())->all(); + + if ($to === []) { + return; + } + + // One row per recipient. A mail to three addresses is three deliveries, + // and an operator asking "what did this customer get" must not have to + // read someone else's row to find out. + foreach ($to as $address) { + SentMail::create([ + 'customer_id' => $this->customerFor($address)?->id, + 'to' => $address, + 'subject' => (string) $message->getSubject(), + 'mailable' => $mailable, + // Set by the console when an operator writes one themselves; + // see Admin\CustomerMessage, which fills the body in afterwards. + 'from_operator' => false, + 'sent_at' => now(), + ]); + } + } + + /** Whose mail this was, where the address belongs to somebody we know. */ + private function customerFor(string $address): ?Customer + { + return Customer::query()->where('email', $address)->first(); + } +} diff --git a/app/Livewire/Admin/CustomerDetail.php b/app/Livewire/Admin/CustomerDetail.php new file mode 100644 index 0000000..e84883e --- /dev/null +++ b/app/Livewire/Admin/CustomerDetail.php @@ -0,0 +1,143 @@ +authorize('customers.manage'); + + $this->customer = Customer::query()->where('uuid', $uuid)->firstOrFail(); + } + + /** + * Start an answer to one request: subject prefilled, the request remembered. + */ + public function answer(string $uuid): void + { + $this->authorize('customers.manage'); + + $request = $this->customer->supportRequests()->where('uuid', $uuid)->first(); + + if ($request === null) { + return; + } + + $this->answering = $request->uuid; + $this->subject = __('customer_message.re', ['subject' => $request->subject]); + $this->body = ''; + } + + public function cancelAnswer(): void + { + $this->answering = null; + $this->subject = ''; + $this->body = ''; + } + + /** + * Send it, record it, and close the request it answers. + * + * The register row is written here rather than left to the MessageSent + * listener, because this is the one mail whose BODY is worth keeping: an + * operator typed it, and "what exactly did I write to them in March" is a + * question the mail server's log cannot answer either. + */ + public function send(): void + { + $this->authorize('customers.manage'); + + $data = $this->validate(); + + Mail::to($this->customer->email)->send( + new OperatorMessageMail($this->customer, $data['subject'], $data['body']) + ); + + // The listener has already written a row for the delivery — it fires on + // every mail this application sends. This adds what only the console + // knows: that a person wrote it, and what they wrote. + SentMail::query() + ->where('customer_id', $this->customer->id) + ->where('to', $this->customer->email) + ->latest('id') + ->limit(1) + ->update(['from_operator' => true, 'body' => $data['body']]); + + if ($this->answering !== null) { + $this->customer->supportRequests() + ->where('uuid', $this->answering) + ->update(['status' => 'answered', 'answered_at' => now()]); + } + + $this->cancelAnswer(); + $this->dispatch('notify', message: __('customer_message.sent')); + } + + public function render() + { + $this->authorize('customers.manage'); + + return view('livewire.admin.customer-detail', [ + 'requests' => $this->customer->supportRequests() + ->with('instance') + ->latest('id') + ->get(), + // The whole conversation as it left here, newest first. Capped: + // this is a page, not an export, and a customer of three years has + // a few hundred rows nobody scrolls. + 'mails' => SentMail::query() + ->where('customer_id', $this->customer->id) + ->latest('sent_at') + ->limit(50) + ->get(), + 'instance' => $this->customer->instances()->latest('id')->first(), + 'subscription' => $this->customer->subscriptions()->latest('id')->first(), + ]); + } +} diff --git a/app/Livewire/Admin/MailLog.php b/app/Livewire/Admin/MailLog.php new file mode 100644 index 0000000..645338b --- /dev/null +++ b/app/Livewire/Admin/MailLog.php @@ -0,0 +1,56 @@ +authorize('customers.manage'); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render() + { + $this->authorize('customers.manage'); + + $mails = SentMail::query() + ->with('customer') + ->when($this->search !== '', function ($q) { + $term = '%'.$this->search.'%'; + $q->where(fn ($w) => $w->where('to', 'like', $term)->orWhere('subject', 'like', $term)); + }) + ->latest('sent_at') + ->latest('id') + ->paginate(30); + + return view('livewire.admin.mail-log', ['mails' => $mails]); + } +} diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index fe5d3a5..7987e00 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -297,11 +297,23 @@ class Overview extends Component $failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count(); if ($failedRuns > 0) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns]), + // Every notice carries the page that shows the thing it is + // about. A warning an operator cannot act on from where they + // read it is a warning they go looking for by hand, and the + // count in the header was not even a link to the list. + 'route' => 'admin.provisioning', + ]; } foreach (Host::query()->where('status', 'error')->pluck('name') as $name) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.host_error', ['host' => $name]), + 'route' => 'admin.hosts', + ]; } $silent = Host::query() @@ -311,9 +323,13 @@ class Overview extends Component ->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES))) ->pluck('name'); foreach ($silent as $name) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_silent', [ - 'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES, - ])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.host_silent', [ + 'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES, + ]), + 'route' => 'admin.hosts', + ]; } // Distinct instances, not targets: one instance can be watched by @@ -326,7 +342,11 @@ class Overview extends Component ->distinct() ->count('instance_id'); if ($down > 0) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.monitoring_down', ['n' => $down]), + 'route' => 'admin.instances', + ]; } // Capacity, before an order finds out. @@ -340,7 +360,7 @@ class Overview extends Component // the decision; "buy a server big enough for the package I am selling" // is. foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.no_capacity', [ + $notices[] = ['level' => 'warning', 'route' => 'admin.capacity', 'text' => __('admin.notice.no_capacity', [ 'plan' => $plan['name'], 'needs' => $plan['needs'], 'largest' => $plan['largest'], diff --git a/app/Mail/OperatorMessageMail.php b/app/Mail/OperatorMessageMail.php new file mode 100644 index 0000000..b103038 --- /dev/null +++ b/app/Mail/OperatorMessageMail.php @@ -0,0 +1,51 @@ +mailer('cp_'.MailPurpose::SUPPORT); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope(MailPurpose::SUPPORT, $this->subjectLine); + } + + public function content(): Content + { + return new Content(view: 'mail.operator-message', with: [ + 'name' => $this->customer->name, + 'bodyText' => $this->bodyText, + ]); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 5165bdf..2360b25 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -118,6 +118,22 @@ class Customer extends Model return $this->hasMany(Instance::class); } + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + public function supportRequests(): HasMany + { + return $this->hasMany(SupportRequest::class); + } + + /** Everything this installation has sent to them, newest first at the call site. */ + public function sentMails(): HasMany + { + return $this->hasMany(SentMail::class); + } + /** * True if this address already belongs to a customer identity — R21's * other direction, checked before an `operators` row is created or diff --git a/app/Models/SentMail.php b/app/Models/SentMail.php new file mode 100644 index 0000000..deef9f7 --- /dev/null +++ b/app/Models/SentMail.php @@ -0,0 +1,56 @@ + 'boolean', + 'sent_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * The mailable's short name, for a column that has to fit. + * + * "OrderConfirmationMail", not "App\Mail\OrderConfirmationMail" — the + * namespace is the same on every row and tells the reader nothing. + */ + public function kind(): string + { + return $this->mailable === null ? '—' : class_basename($this->mailable); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 802e947..f1ee8d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use App\Http\Middleware\EnsureAdmin; use App\Http\Middleware\EnsureCustomerActive; use App\Http\Middleware\RestrictAdminHost; use App\Http\Middleware\RestrictConsoleNetwork; +use App\Listeners\RecordSentMail; use App\Listeners\RecordSignInDevice; use App\Mail\MaintenanceCancelledMail; use App\Mail\Transport\MailboxTransport; @@ -165,6 +166,12 @@ class AppServiceProvider extends ServiceProvider return null; }); + // The register of what this installation has sent, and to whom. One + // listener rather than a call in each mailable: a register that has to + // be remembered at every call site is a register with holes in it, and + // the holes are the mails nobody thought about. + Event::listen(MessageSent::class, RecordSentMail::class); + // Stamp a maintenance-notification ledger row as delivered only once the // mail is actually sent (the X-CP-Notification header carries the id). // Until then sent_at stays null → the row is a retryable marker. If an diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index fb8aa82..eada042 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -60,6 +60,10 @@ final class Navigation // site-visibility switch invites somebody to change one in passing. ['admin.finance', 'receipt', 'finance', 'site.manage'], ['admin.invoices', 'file-text', 'invoices', 'site.manage'], + // What this installation has sent, and to whom. Under Betrieb + // rather than System: it is read while answering a customer, + // not while configuring the machine. + ['admin.mail-log', 'send', 'mail_log', 'customers.manage'], ]], ['label' => __('admin.nav_group.system'), 'items' => [ ['admin.mail', 'mail', 'mail', 'mail.manage'], diff --git a/database/migrations/2026_07_30_100000_record_every_mail_that_left_the_system.php b/database/migrations/2026_07_30_100000_record_every_mail_that_left_the_system.php new file mode 100644 index 0000000..9eea417 --- /dev/null +++ b/database/migrations/2026_07_30_100000_record_every_mail_that_left_the_system.php @@ -0,0 +1,63 @@ +id(); + $table->uuid()->unique(); + + // Nullable, because not every mail has a customer behind it: a + // password reset can be triggered for an address that never bought + // anything, and an operator invitation is not a customer at all. + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + + $table->string('to'); + $table->string('subject'); + // The mailable class, so a filter can say "every invoice mail" + // without matching on a subject line that changes with the wording. + $table->string('mailable')->nullable(); + // Written by a person in the console rather than by the system. + $table->boolean('from_operator')->default(false); + $table->text('body')->nullable(); + + // Recorded on MessageSent — i.e. the transport accepted it. That is + // the strongest thing an application can honestly claim; what the + // receiving server did with it afterwards is not ours to know. + $table->timestamp('sent_at'); + $table->timestamps(); + + $table->index(['customer_id', 'sent_at']); + $table->index('sent_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('sent_mails'); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index dc84ddd..9d9284f 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'mail_log' => 'Postausgang', 'overview' => 'Übersicht', 'customers' => 'Kunden', 'instances' => 'Instanzen', @@ -36,7 +37,7 @@ return [ 'overview_title' => 'Fleet-Übersicht', 'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.', 'systems_ok' => 'Nichts gemeldet', - 'systems_notices' => ':n Hinweis(e)', + 'systems_notices' => '{1} :n Hinweis|[2,*] :n Hinweise', 'kpi' => [ 'customers' => 'Kunden', diff --git a/lang/de/customer_detail.php b/lang/de/customer_detail.php new file mode 100644 index 0000000..d1fe5db --- /dev/null +++ b/lang/de/customer_detail.php @@ -0,0 +1,21 @@ + 'Alle Kunden', + 'contract' => 'Vertrag', + 'plan' => 'Paket', + 'instance' => 'Instanz', + 'since' => 'Kunde seit', + 'vat_id' => 'UID', + 'write_invoice' => 'Rechnung schreiben', + + 'requests' => 'Anfragen aus dem Kundenbereich', + 'no_requests' => 'Bisher keine Anfrage.', + 'answer' => 'Antworten', + + 'mails' => 'Gesendete E-Mails', + 'no_mails' => 'An diesen Kunden ging bisher keine E-Mail.', + 'all_mails' => 'Alle E-Mails', + 'by_hand' => 'Von Hand', +]; diff --git a/lang/de/customer_message.php b/lang/de/customer_message.php new file mode 100644 index 0000000..1790671 --- /dev/null +++ b/lang/de/customer_message.php @@ -0,0 +1,20 @@ + 'Nachricht schreiben', + 'subject' => 'Betreff', + 'body' => 'Nachricht', + 'body_hint' => 'Was Sie dem Kunden antworten möchten.', + 'send' => 'Senden', + 'note' => 'Geht an :email und wird im Postausgang festgehalten.', + 'sent' => 'Nachricht gesendet und protokolliert.', + + 'answering' => 'Antwort auf eine Anfrage', + 'cancel_answer' => 'Bezug aufheben', + 're' => 'Re: :subject', + + 'mail_heading' => 'Nachricht von CluPilot', + 'mail_greeting' => 'Guten Tag :name,', +]; diff --git a/lang/de/mail_log.php b/lang/de/mail_log.php new file mode 100644 index 0000000..b3c0e33 --- /dev/null +++ b/lang/de/mail_log.php @@ -0,0 +1,16 @@ + 'Postausgang', + 'subtitle' => 'Jede E-Mail, die diese Installation verschickt hat — wann, an wen, mit welchem Betreff. Eine Zeile bedeutet: der Mailserver hat sie angenommen.', + 'search' => 'Suchen', + 'search_hint' => 'Adresse oder Betreff', + 'empty' => 'Es wurde noch nichts verschickt.', + 'col_when' => 'Wann', + 'col_to' => 'An', + 'col_subject' => 'Betreff', + 'col_kind' => 'Art', + 'by_hand' => 'Von Hand', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 2aa8383..058c2eb 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'mail_log' => 'Mail sent', 'overview' => 'Overview', 'customers' => 'Customers', 'instances' => 'Instances', @@ -36,7 +37,7 @@ return [ 'overview_title' => 'Fleet overview', 'overview_sub' => 'The whole platform at a glance.', 'systems_ok' => 'Nothing reported', - 'systems_notices' => ':n notice(s)', + 'systems_notices' => '{1} :n notice|[2,*] :n notices', 'kpi' => [ 'customers' => 'Customers', diff --git a/lang/en/customer_detail.php b/lang/en/customer_detail.php new file mode 100644 index 0000000..adaee7f --- /dev/null +++ b/lang/en/customer_detail.php @@ -0,0 +1,21 @@ + 'All customers', + 'contract' => 'Contract', + 'plan' => 'Package', + 'instance' => 'Instance', + 'since' => 'Customer since', + 'vat_id' => 'VAT ID', + 'write_invoice' => 'Write an invoice', + + 'requests' => 'Requests from the portal', + 'no_requests' => 'No request so far.', + 'answer' => 'Answer', + + 'mails' => 'Mail sent', + 'no_mails' => 'Nothing has been sent to this customer yet.', + 'all_mails' => 'All mail', + 'by_hand' => 'By hand', +]; diff --git a/lang/en/customer_message.php b/lang/en/customer_message.php new file mode 100644 index 0000000..9bfd448 --- /dev/null +++ b/lang/en/customer_message.php @@ -0,0 +1,20 @@ + 'Write a message', + 'subject' => 'Subject', + 'body' => 'Message', + 'body_hint' => 'What you would like to tell the customer.', + 'send' => 'Send', + 'note' => 'Goes to :email and is recorded in the mail register.', + 'sent' => 'Message sent and recorded.', + + 'answering' => 'Answering a request', + 'cancel_answer' => 'Detach', + 're' => 'Re: :subject', + + 'mail_heading' => 'A message from CluPilot', + 'mail_greeting' => 'Hello :name,', +]; diff --git a/lang/en/mail_log.php b/lang/en/mail_log.php new file mode 100644 index 0000000..afc4868 --- /dev/null +++ b/lang/en/mail_log.php @@ -0,0 +1,16 @@ + 'Mail sent', + 'subtitle' => 'Every mail this installation has sent — when, to whom, with what subject. A row means the mail server accepted it.', + 'search' => 'Search', + 'search_hint' => 'Address or subject', + 'empty' => 'Nothing has been sent yet.', + 'col_when' => 'When', + 'col_to' => 'To', + 'col_subject' => 'Subject', + 'col_kind' => 'Kind', + 'by_hand' => 'By hand', +]; diff --git a/resources/views/livewire/admin/customer-detail.blade.php b/resources/views/livewire/admin/customer-detail.blade.php new file mode 100644 index 0000000..26ec441 --- /dev/null +++ b/resources/views/livewire/admin/customer-detail.blade.php @@ -0,0 +1,146 @@ +
+
+
+ + ‹ {{ __('customer_detail.back') }} + +

{{ $customer->name }}

+

{{ $customer->email }}

+
+ + {{ __('customers.status.'.$customer->status) }} + +
+ +
+
+ {{-- ── Write to them ──────────────────────────────────────────── + The point of the page. An operator answering a question used to + have the name in one window and a mail client in another, and + afterwards nothing in the console said an answer had ever been + given. --}} +
+
+

{{ __('customer_message.title') }}

+ @if ($answering) + {{ __('customer_message.answering') }} + + @endif +
+ +
+ +
+ + + @error('body')

{{ $message }}

@enderror +
+
+ + {{ __('customer_message.send') }} + +

{{ __('customer_message.note', ['email' => $customer->email]) }}

+
+ +
+ + {{-- ── What they asked ──────────────────────────────────────── --}} +
+

{{ __('customer_detail.requests') }}

+ + @if ($requests->isEmpty()) +

{{ __('customer_detail.no_requests') }}

+ @else +
    + @foreach ($requests as $request) +
  • +
    + {{ $request->subject }} + + {{ __('support.status.'.$request->status) }} + + {{ $request->created_at->local()->isoFormat('D. MMM YYYY, HH:mm') }} +
    + {{-- The customer's own words, whole. Reading the + question was the other reason to leave the + console. --}} +

    {{ $request->body }}

    + @if ($request->isOpen()) + + {{ __('customer_detail.answer') }} + + @endif +
  • + @endforeach +
+ @endif +
+ + {{-- ── What we sent ───────────────────────────────────────────── + The delivery register, filtered to this customer: when, which + mail, and — where an operator wrote it — what it said. --}} +
+
+

{{ __('customer_detail.mails') }}

+ + {{ __('customer_detail.all_mails') }} › + +
+ + @if ($mails->isEmpty()) +

{{ __('customer_detail.no_mails') }}

+ @else +
    + @foreach ($mails as $mail) +
  • +
    + {{ $mail->subject }} + @if ($mail->from_operator) + {{ __('customer_detail.by_hand') }} + @endif + + {{ $mail->sent_at->local()->isoFormat('D. MMM YYYY, HH:mm') }} + +
    + @if ($mail->body) +

    {{ $mail->body }}

    + @else +

    {{ $mail->kind() }}

    + @endif +
  • + @endforeach +
+ @endif +
+
+ + {{-- ── Who they are ─────────────────────────────────────────────── --}} +
+

{{ __('customer_detail.contract') }}

+ +
+ @foreach ([ + __('customer_detail.plan') => $subscription ? __('billing.plan.'.$subscription->plan) : '—', + __('customer_detail.instance') => $instance?->subdomain ?? '—', + __('customer_detail.since') => $customer->created_at->local()->isoFormat('D. MMM YYYY'), + __('customer_detail.vat_id') => $customer->vat_id ?: '—', + ] as $term => $value) +
+
{{ $term }}
+
{{ $value }}
+
+ @endforeach +
+ + {{-- Prefilled with this customer, so writing an invoice for the work + just agreed is one click from the conversation about it. --}} + + {{ __('customer_detail.write_invoice') }} + +
+
+
diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index 9dc80b1..5d3b6df 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -21,7 +21,12 @@ @forelse ($rows as $r) -

{{ $r['name'] }}

+ {{-- The row leads somewhere now. The list + was the end of the road: an operator + answering a question had the name here + and the mail client in another window. --}} + {{ $r['name'] }}

{{ $r['instance'] }}.clupilot.com

diff --git a/resources/views/livewire/admin/mail-log.blade.php b/resources/views/livewire/admin/mail-log.blade.php new file mode 100644 index 0000000..6661c09 --- /dev/null +++ b/resources/views/livewire/admin/mail-log.blade.php @@ -0,0 +1,58 @@ +
+
+

{{ __('mail_log.title') }}

+

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

+
+ +
+
+
+ +
+
+ + @if ($mails->isEmpty()) +

{{ __('mail_log.empty') }}

+ @else + + + + + + + + + + + @foreach ($mails as $mail) + + {{-- R19: stored in UTC, read on the wall clock. --}} + + + + + + @endforeach + +
{{ __('mail_log.col_when') }}{{ __('mail_log.col_to') }}{{ __('mail_log.col_subject') }}{{ __('mail_log.col_kind') }}
+ {{ $mail->sent_at->local()->isoFormat('D. MMM YYYY, HH:mm:ss') }} + + @if ($mail->customer) + {{ $mail->customer->name }} +

{{ $mail->to }}

+ @else + {{ $mail->to }} + @endif +
+ {{ $mail->subject }} + @if ($mail->from_operator) + {{ __('mail_log.by_hand') }} + @endif + {{ $mail->kind() }}
+ +
{{ $mails->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/overview.blade.php b/resources/views/livewire/admin/overview.blade.php index 3fffb83..1e7b259 100644 --- a/resources/views/livewire/admin/overview.blade.php +++ b/resources/views/livewire/admin/overview.blade.php @@ -8,9 +8,13 @@ {{ __('admin.systems_ok') }} @else - - {{ __('admin.systems_notices', ['n' => $noticeCount]) }} - + {{-- A link, not a label. It said "4 Hinweis(e)" and led nowhere: + whoever read it had to know that the list was a card further + down the same page, and the plural was a bracket rather than a + word. --}} + + {{ trans_choice('admin.systems_notices', $noticeCount, ['n' => $noticeCount]) }} + @endif

{{ __('admin.overview_sub') }}

@@ -118,14 +122,27 @@ - {{-- Notices --}} -
+ {{-- Notices. Each one links to the page that shows the thing it is + about — a warning somebody has to go and find by hand is a warning + that waits until the next time they happen to look. --}} +

{{ __('admin.alerts') }}

-