Show the customer, and write the answers once
tests / pest (push) Failing after 9m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

Two complaints, one cause: the customer page was a compose box with three
lists under it. It showed nothing ABOUT the customer, and every answer was
typed from scratch.

The page is five tabs now. Stammdaten — contact, phone, since when,
language, and the tax standing: consumer or business, with "nobody asked"
saying so rather than passing for business, because that answer decides
whether a withdrawal right exists at all. Paket — the contract's own
FROZEN figures, the modules at the price they were booked at, and the
machine: address, state, host, datacenter. Zahlungen — invoices with net,
gross and their PDF, and the orders beside them, because an order is a
purchase and an invoice is a document about one. Nachrichten — the portal
requests in the customer's own words, the mail they sent, the mail we
sent. Schreiben — last, because it is what you do after reading.

The tab is in the query string, like Settings and Integrations: a reload,
a bookmark and the back button all land where the operator was, and a link
can point at the tab that matters.

And templates, under Betrieb → E-Mail-Vorlagen. Most customer questions
are the same five questions, and typing the answer again every time is how
two customers get told two different things about one subject. A template
is INSERTED into the compose field with the customer's own details filled
in — never sent as it stands, because the last two sentences always belong
to the person asking. What goes out is what the operator saw and edited.

Three decisions worth naming. {{contact}} takes the contact person where
one is on record and the company name otherwise, because "Guten Tag
Beispiel GmbH" is the line that gives away a mail written by a database.
{{amount}} is the gross figure — what the price sheet quoted them and what
their bank statement says. And a typo comes out as {{kunde}}, standing
there in the field where the operator sees it, rather than as an empty
string that produces "Guten Tag ,". A placeholder with no value for this
customer stays empty; nothing is invented.

Placeholders are named in English because they are identifiers, not prose:
a template has to survive somebody switching the console's language. The
list the page documents is the renderer's own, so a token added in one
place appears in the other without being written down twice.

Blade's echo tag ends at the first }} it finds, which turned a literal
placeholder in the documentation into compiled nonsense. Assembled in PHP
now, and caught by the test that renders the page rather than by somebody
opening it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus v1.3.36
nexxo 2026-07-29 22:37:30 +02:00
parent e376b571be
commit 33566cd404
23 changed files with 1701 additions and 125 deletions

View File

@ -1 +1 @@
1.3.35
1.3.36

View File

@ -4,36 +4,57 @@ namespace App\Livewire\Admin;
use App\Mail\OperatorMessageMail;
use App\Models\Customer;
use App\Models\InboundMail;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\MailTemplate;
use App\Models\Order;
use App\Models\SentMail;
use App\Models\Subscription;
use App\Models\SupportRequest;
use App\Services\Mail\MailTemplateRenderer;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* One customer, everything about them, and a box to write to them.
* One customer: who they are, what they bought, what they paid, what was said.
*
* The console had a customer LIST and nothing behind it: an operator answering
* a question had the name in one window, the mail client in another, and no
* record anywhere afterwards that an answer had been given at all. So the
* conversation happened outside the system that knows who the customer is.
* The first version of this page was a compose box with three lists under it,
* and the reported complaint was exactly right it showed nothing ABOUT the
* customer. An operator on the phone needs the address, the package, whether
* the last invoice was paid and what was already said to them, and none of that
* was here.
*
* This page is the other half. What they bought, what they asked, what we sent
* them and a field to write the next message, which goes out as mail from the
* support mailbox and is written into the register on its way.
* TABBED, like Settings and Integrations, for the same reason: this is four
* different questions, and stacking them makes a page nobody reads to the
* bottom. Writing is the LAST tab, because it is the thing you do after
* reading and it is where the templates come in, so the same answer is not
* retyped differently for every customer.
*
* Reading their INCOMING mail is not here yet. That needs the mailbox itself
* polled over IMAP, which is a mail-server credential and a dependency; until
* then the requests a customer files through the portal appear here in full,
* and a mail they write instead still has to be read in the mail client.
* The open tab is in the query string: a reload, a bookmark and the back button
* all land where the operator was, and a link can point straight at the tab
* that matters ("…/customers/x?tab=billing").
*/
#[Layout('layouts.admin')]
class CustomerDetail extends Component
{
/**
* The tabs, in order. The list IS the schema: it validates the query
* string, builds the bar and decides what renders.
*/
public const TABS = ['overview', 'package', 'billing', 'messages', 'compose'];
public Customer $customer;
/** What the operator is writing. Empty is the normal state of this page. */
#[Url(except: 'overview')]
public string $tab = 'overview';
// ---- Writing to them ----
#[Validate('required|string|max:200')]
public string $subject = '';
@ -45,7 +66,7 @@ class CustomerDetail extends Component
*
* Answering marks it answered which is the whole reason to answer from
* here rather than from a mail client, where the request stays open for
* ever because nothing told it otherwise.
* ever because nothing ever told it otherwise.
*/
public ?string $answering = null;
@ -54,11 +75,48 @@ class CustomerDetail extends Component
$this->authorize('customers.manage');
$this->customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
if (! in_array($this->tab, self::TABS, true)) {
$this->tab = self::TABS[0];
}
}
/**
* Start an answer to one request: subject prefilled, the request remembered.
* Put a template into the fields, filled in for THIS customer.
*
* Inserted, never sent: the placeholders come out filled and the last two
* sentences are still the operator's to write. What goes out is what they
* saw and edited.
*/
public function useTemplate(string $uuid): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->where('active', true)->first();
if ($template === null) {
return;
}
$filled = app(MailTemplateRenderer::class)->render(
$template,
$this->customer,
Auth::guard('operator')->user(),
);
// The subject is only taken when the operator has not written one — a
// template that overwrites what somebody typed is a template that eats
// work. The body is replaced, because that is what "use this template"
// means and the field is empty in the case that matters.
if (trim($this->subject) === '') {
$this->subject = $filled['subject'];
}
$this->body = $filled['body'];
$this->tab = 'compose';
}
/** Start an answer to one request: subject prefilled, the request remembered. */
public function answer(string $uuid): void
{
$this->authorize('customers.manage');
@ -72,6 +130,7 @@ class CustomerDetail extends Component
$this->answering = $request->uuid;
$this->subject = __('customer_message.re', ['subject' => $request->subject]);
$this->body = '';
$this->tab = 'compose';
}
public function cancelAnswer(): void
@ -85,8 +144,8 @@ class CustomerDetail extends Component
* 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
* listener alone, 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
@ -99,9 +158,6 @@ class CustomerDetail extends Component
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)
@ -124,20 +180,51 @@ class CustomerDetail extends Component
$this->authorize('customers.manage');
return view('livewire.admin.customer-detail', [
'requests' => $this->customer->supportRequests()
->with('instance')
'tabs' => self::TABS,
'subscription' => $this->subscription(),
'instance' => Instance::query()
->where('customer_id', $this->customer->id)
->latest('id')
->first(),
'orders' => Order::query()
->where('customer_id', $this->customer->id)
->latest('id')
->limit(20)
->get(),
'invoices' => Invoice::query()
->where('customer_id', $this->customer->id)
->orderByDesc('issued_on')
->orderByDesc('id')
->limit(20)
->get(),
'requests' => $this->customer->supportRequests()->latest('id')->get(),
'inbound' => InboundMail::query()
->where('customer_id', $this->customer->id)
->latest('received_at')
->limit(30)
->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)
->limit(30)
->get(),
'instance' => $this->customer->instances()->latest('id')->first(),
'subscription' => $this->customer->subscriptions()->latest('id')->first(),
'templates' => MailTemplate::query()->usable()->get(),
// Counted for the tab bar, so an operator sees where the unread
// things are before opening anything.
'openRequests' => $this->customer->supportRequests()
->whereNotIn('status', SupportRequest::CLOSED)
->count(),
]);
}
/** The contract that is being billed, or the most recent one there was. */
private function subscription(): ?Subscription
{
return Subscription::query()
->where('customer_id', $this->customer->id)
->with('addons')
->orderByRaw("status = 'active' DESC")
->latest('id')
->first();
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire\Admin;
use App\Models\MailTemplate;
use App\Services\Mail\MailTemplateRenderer;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit one template in a modal.
*
* R20: a textarea growing inside a table row pushes every column beside it and
* reads like a rendering fault rather than a form. A modal is also the only
* place a five-line body has room.
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* again and reads the record itself rather than trusting a property the browser
* hydrated.
*/
class EditMailTemplate extends ModalComponent
{
public string $uuid = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|max:255')]
public string $subject = '';
#[Validate('required|string|max:5000')]
public string $body = '';
public bool $active = true;
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $template->name;
$this->subject = $template->subject;
$this->body = $template->body;
$this->active = (bool) $template->active;
}
public function save()
{
$this->authorize('customers.manage');
$data = $this->validate();
MailTemplate::query()->where('uuid', $this->uuid)->update([
'name' => trim($data['name']),
'subject' => trim($data['subject']),
'body' => $data['body'],
'active' => $this->active,
]);
$this->dispatch('notify', message: __('templates.saved'));
return $this->redirectRoute('admin.templates', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-mail-template', [
'placeholders' => MailTemplateRenderer::PLACEHOLDERS,
]);
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Livewire\Admin;
use App\Models\MailTemplate;
use App\Services\Mail\MailTemplateRenderer;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The answers an operator gives over and over, written once.
*
* Most customer questions are the same five questions, and typing the answer
* again every time is how two customers get told two different things about the
* same subject.
*
* A template is not sent from here and never sent by itself: it is inserted into
* the compose field on a customer's page, placeholders already filled from that
* customer's record, and the last two sentences are still the operator's to
* write. What goes out is what they saw and edited.
*
* Creating happens in the form on this page R20 exempts a create form that IS
* the page. Editing happens in a modal (Admin\EditMailTemplate), because a
* textarea growing inside a table row is the row-height jump R20 exists to stop.
*/
#[Layout('layouts.admin')]
class MailTemplates extends Component
{
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|max:255')]
public string $subject = '';
#[Validate('required|string|max:5000')]
public string $body = '';
public function mount(): void
{
$this->authorize('customers.manage');
}
public function create(): void
{
$this->authorize('customers.manage');
$data = $this->validate();
MailTemplate::create([
'name' => trim($data['name']),
'subject' => trim($data['subject']),
'body' => $data['body'],
// At the end of the list, where a new one belongs until somebody
// decides otherwise.
'sort' => (int) MailTemplate::query()->max('sort') + 10,
'active' => true,
]);
$this->reset(['name', 'subject', 'body']);
$this->dispatch('notify', message: __('templates.created'));
}
/**
* Retire one, or bring it back.
*
* Not deleted: an answer that is no longer given is still the answer
* somebody was given last year, and the register of sent mail refers to it.
*/
public function toggleActive(string $uuid): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->first();
if ($template !== null) {
$template->update(['active' => ! $template->active]);
}
}
/** Move one up or down the list an operator picks from. */
public function move(string $uuid, int $by): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->first();
if ($template !== null) {
$template->update(['sort' => max(0, $template->sort + $by)]);
}
}
public function render()
{
$this->authorize('customers.manage');
return view('livewire.admin.mail-templates', [
'templates' => MailTemplate::query()->orderBy('sort')->orderBy('name')->get(),
// The list IS the documentation: a token added to the renderer
// appears here without anybody remembering to write it down twice.
'placeholders' => MailTemplateRenderer::PLACEHOLDERS,
]);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* One answer, written once and reused.
*
* Most customer questions are the same five questions, and typing the answer
* again every time is how two customers get told two different things about the
* same subject.
*
* A template is inserted into the compose field, never sent by itself: the
* placeholders come out filled from the customer's record and the last two
* sentences are still the operator's to write. See MailTemplateRenderer for the
* placeholders and why they are named in English.
*/
class MailTemplate extends Model
{
use HasFactory;
use HasUuid;
protected $fillable = ['name', 'subject', 'body', 'sort', 'active'];
protected function casts(): array
{
return [
'sort' => 'integer',
'active' => 'boolean',
];
}
/** In the order an operator thinks in, which is what `sort` is for. */
public function scopeUsable(Builder $query): Builder
{
return $query->where('active', true)->orderBy('sort')->orderBy('name');
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Services\Mail;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\MailTemplate;
use App\Models\Operator;
use App\Models\Subscription;
use App\Support\CompanyProfile;
use Illuminate\Support\Number;
/**
* A template with the customer's own details put in.
*
* ## Why the placeholders are named in English
*
* They are identifiers, not prose. A template written in German uses
* `{{customer}}` in a German sentence for the same reason a database column is
* called `customer_id` the name has to survive somebody switching the
* console's language, and a placeholder that only works in one locale is a
* template that breaks the day the interface is translated. What the OPERATOR
* reads beside the field is translated; the token is not.
*
* ## Why an unknown placeholder is left standing
*
* A typo comes out as `{{kunde}}` in the compose field, where the operator sees
* it before anything is sent and fixes it. Replacing it with an empty string
* would produce "Guten Tag ," a mail that looks sent by a machine, which is
* exactly the impression templates exist to avoid.
*
* ## Why nothing is invented
*
* A customer with no contract gets an empty `{{plan}}`, not "Start" and not
* "kein Paket". The operator is looking at the text and can write what belongs
* there; a guess in a mail to a paying customer is worse than a gap.
*/
final class MailTemplateRenderer
{
/**
* Every placeholder, with the key of the sentence that explains it.
*
* The list IS the documentation: the template page renders it, and a token
* added here appears there without anybody remembering to add it twice.
*/
public const PLACEHOLDERS = [
'customer' => 'templates.token.customer',
'contact' => 'templates.token.contact',
'email' => 'templates.token.email',
'plan' => 'templates.token.plan',
'amount' => 'templates.token.amount',
'instance' => 'templates.token.instance',
'operator' => 'templates.token.operator',
'company' => 'templates.token.company',
];
/**
* Subject and body, filled in.
*
* @return array{subject: string, body: string}
*/
public function render(MailTemplate $template, Customer $customer, ?Operator $operator = null): array
{
$values = $this->values($customer, $operator);
return [
'subject' => $this->fill($template->subject, $values),
'body' => $this->fill($template->body, $values),
];
}
/**
* What each placeholder stands for, for this customer.
*
* @return array<string, string>
*/
public function values(Customer $customer, ?Operator $operator = null): array
{
$subscription = Subscription::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'past_due'])
->latest('id')
->first();
$instance = Instance::query()
->where('customer_id', $customer->id)
->whereNotIn('status', ['ended', 'failed'])
->latest('id')
->first();
return [
'customer' => (string) $customer->name,
// The person, where one is on record; the company otherwise. A
// salutation is written to somebody, and "Guten Tag Beispiel GmbH"
// is how a mail announces that it came out of a database.
'contact' => (string) ($customer->contact_name ?: $customer->name),
'email' => (string) $customer->email,
'plan' => $subscription === null ? '' : __('billing.plan.'.$subscription->plan),
// Gross, because that is the figure the customer knows: it is what
// the price sheet quoted them and what their bank statement says.
'amount' => $subscription === null ? '' : Number::currency(
$this->grossCents($subscription) / 100,
in: (string) ($subscription->currency ?: 'EUR'),
locale: app()->getLocale(),
),
'instance' => (string) ($instance?->subdomain ?? ''),
'operator' => (string) ($operator?->name ?? ''),
'company' => (string) CompanyProfile::get('name', ''),
];
}
/** @param array<string, string> $values */
private function fill(string $text, array $values): string
{
return preg_replace_callback(
// Whitespace inside the braces is tolerated: an operator typing
// "{{ customer }}" means the same thing as "{{customer}}", and
// failing on a space would be a rule nobody can see.
'/\{\{\s*([a-z_]+)\s*\}\}/i',
// Unknown tokens are left exactly as they were — see the class
// docblock. The operator reads this before anything is sent.
fn (array $m) => $values[strtolower($m[1])] ?? $m[0],
$text,
) ?? $text;
}
/**
* The contract's monthly figure with VAT on it.
*
* The domestic rate, from the same place an invoice reads it. Reverse charge
* is deliberately not applied: this figure goes into a sentence a person
* reads, and a business quoting itself a net price in a mail is a different
* problem from a document that has to be lawful.
*/
private function grossCents(Subscription $subscription): int
{
$net = (int) $subscription->price_cents;
return (int) round($net * (1 + CompanyProfile::taxRate() / 100));
}
}

View File

@ -65,6 +65,10 @@ final class Navigation
// somebody, not while configuring a machine.
['admin.inbox', 'mail', 'inbox', 'customers.manage'],
['admin.mail-log', 'send', 'mail_log', 'customers.manage'],
// The answers, written once. Beside the two mail pages because
// that is where they are used, not under System — nothing about
// a template configures the installation.
['admin.templates', 'file-text', 'templates', 'customers.manage'],
]],
['label' => __('admin.nav_group.system'), 'items' => [
['admin.mail', 'mail', 'mail', 'mail.manage'],

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The answers an operator gives over and over, written once.
*
* Most customer questions are the same five questions. Typing the answer again
* every time is how two customers get told two different things about the same
* subject, and how the third one gets a sentence that is nearly right.
*
* A template is text with placeholders in it. It is INSERTED into the compose
* field with the placeholders already filled from the customer's own record
* not sent as it stands, because the last two sentences are always specific to
* the person asking. What goes out is what the operator saw and edited.
*
* `sort` exists so the list reads in the order the operator thinks in, which is
* neither alphabetical nor the order they happened to be written in.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('mail_templates', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
// What the operator picks it by, in a dropdown of a dozen.
$table->string('name');
// Prefilled into the subject line too; a template that fills only
// the body leaves half the mail to be typed every time.
$table->string('subject');
$table->text('body');
$table->unsignedSmallInteger('sort')->default(0);
// Retired rather than deleted: an answer that is no longer given is
// still the answer somebody was given last year.
$table->boolean('active')->default(true);
$table->timestamps();
$table->index(['active', 'sort']);
});
}
public function down(): void
{
Schema::dropIfExists('mail_templates');
}
};

View File

@ -13,6 +13,7 @@ return [
'nav' => [
'inbox' => 'Posteingang',
'mail_log' => 'Postausgang',
'templates' => 'E-Mail-Vorlagen',
'overview' => 'Übersicht',
'customers' => 'Kunden',
'instances' => 'Instanzen',

View File

@ -1,21 +1,80 @@
<?php
// Ein Kunde, alles über ihn, und ein Feld, um ihm zu schreiben.
// Ein Kunde: wer er ist, was er gebucht hat, was er gezahlt hat, was gesagt
// wurde — und am Ende der Reiter, um ihm zu schreiben.
return [
'back' => 'Alle Kunden',
'contract' => 'Vertrag',
'plan' => 'Paket',
'instance' => 'Instanz',
'since' => 'Kunde seit',
'vat_id' => 'UID',
'write_invoice' => 'Rechnung schreiben',
'tab' => [
'overview' => 'Stammdaten',
'package' => 'Paket',
'billing' => 'Zahlungen',
'messages' => 'Nachrichten',
'compose' => 'Schreiben',
],
// Stammdaten
'identity' => 'Kunde',
'company' => 'Firma',
'contact' => 'Ansprechpartner',
'email' => 'E-Mail',
'phone' => 'Telefon',
'since' => 'Kunde seit',
'locale' => 'Sprache',
'tax' => 'Steuerliche Einordnung',
'type' => 'Kundenart',
'type_consumer' => 'Verbraucher',
'type_business' => 'Unternehmen',
'type_unknown' => 'Nicht erfasst — gilt als Verbraucher',
'vat_id' => 'UID',
'verified' => 'geprüft',
'unverified' => 'ungeprüft',
'stripe' => 'Stripe-Kunde',
'address' => 'Rechnungsadresse',
'no_address' => 'Keine Rechnungsadresse hinterlegt.',
// Paket
'no_contract' => 'Dieser Kunde hat keinen Vertrag — weder laufend noch beendet.',
'price' => 'Preis',
'net_per' => 'netto :term',
'term_monthly' => 'pro Monat',
'term_yearly' => 'pro Jahr',
'quota' => 'Speicher',
'traffic' => 'Datenvolumen',
'seats' => 'Benutzer',
'started' => 'Beginn',
'period_end' => 'Laufend bis',
'cancelled' => 'Gekündigt am :date.',
'addons' => 'Zusatzmodule',
'addon_until' => '— läuft bis :date',
'machine' => 'Maschine',
'no_instance' => 'Noch keine Instanz angelegt.',
'address_web' => 'Adresse',
'instance_status' => 'Zustand',
'host' => 'Host',
'datacenter' => 'Rechenzentrum',
'disk' => 'Zugeteilt',
// Zahlungen
'invoices' => 'Rechnungen',
'no_invoices' => 'Für diesen Kunden wurde noch keine Rechnung ausgestellt.',
'write_invoice' => 'Rechnung schreiben',
'number' => 'Nummer',
'issued' => 'Datum',
'net' => 'Netto',
'gross' => 'Brutto',
'orders' => 'Bestellungen',
'no_orders' => 'Keine Bestellung erfasst.',
// Nachrichten
'requests' => 'Anfragen aus dem Kundenbereich',
'no_requests' => 'Bisher keine Anfrage.',
'answer' => 'Antworten',
'inbound' => 'E-Mails vom Kunden',
'no_inbound' => 'Von diesem Kunden ist keine E-Mail eingegangen.',
'to_inbox' => 'Posteingang',
'mails' => 'Gesendete E-Mails',
'no_mails' => 'An diesen Kunden ging bisher keine E-Mail.',
'all_mails' => 'Alle E-Mails',
'all_mails' => 'Postausgang',
'by_hand' => 'Von Hand',
];

View File

@ -17,4 +17,9 @@ return [
'mail_heading' => 'Nachricht von CluPilot',
'mail_greeting' => 'Guten Tag :name,',
'templates' => 'Vorlagen',
'templates_note' => 'Einsetzen, anpassen, senden. Die Daten dieses Kunden werden dabei eingetragen.',
'no_templates' => 'Noch keine Vorlage angelegt. Unter „E-Mail-Vorlagen“ schreiben Sie die Antworten einmal auf, die Sie immer wieder geben.',
'manage_templates' => 'Vorlagen',
];

47
lang/de/templates.php Normal file
View File

@ -0,0 +1,47 @@
<?php
// Die Antworten, die man immer wieder gibt — einmal geschrieben.
return [
'title' => 'E-Mail-Vorlagen',
'subtitle' => 'Die Antworten, die Sie immer wieder geben, einmal geschrieben. Eine Vorlage wird beim Schreiben in das Feld eingesetzt — mit den Daten des jeweiligen Kunden schon eingetragen — und dort noch angepasst. Verschickt wird nie die Vorlage, sondern was Sie gelesen und geändert haben.',
'list' => 'Vorhandene Vorlagen',
'empty' => 'Noch keine Vorlage angelegt.',
'new' => 'Neue Vorlage',
'create' => 'Vorlage anlegen',
'created' => 'Vorlage angelegt.',
'saved' => 'Vorlage gespeichert.',
'edit' => 'Bearbeiten',
'edit_title' => 'Vorlage bearbeiten',
'edit_body' => 'Änderungen gelten für künftige Mails. Was schon verschickt wurde, bleibt wie es war.',
'save' => 'Speichern',
'cancel' => 'Abbrechen',
'active' => 'Vorlage wird zur Auswahl angeboten',
'retire' => 'Zurückziehen',
'restore' => 'Wieder anbieten',
'retired' => 'Zurückgezogen',
'up' => 'Nach oben',
'down' => 'Nach unten',
'name' => 'Name',
'name_hint' => 'z. B. Datenübernahme — Angebot',
'subject' => 'Betreff',
'subject_hint' => 'z. B. Ihre Anfrage zur Datenübernahme',
'body' => 'Text',
'body_hint' => "Guten Tag {{contact}},\n\nvielen Dank für Ihre Anfrage zu {{plan}}.\n\n\n\nMit freundlichen Grüßen\n{{operator}}\n{{company}}",
'placeholders' => 'Platzhalter',
'placeholders_note' => 'Diese Wörter werden beim Einsetzen durch die Daten des Kunden ersetzt. Schreibweise mit doppelten geschweiften Klammern; Leerzeichen darin sind erlaubt.',
'unknown_note' => 'Ein Tippfehler wird nicht ersetzt, sondern bleibt sichtbar im Text stehen — so sehen Sie ihn vor dem Senden. Ein Platzhalter, für den es beim Kunden keinen Wert gibt, bleibt leer; erfunden wird nichts.',
'token' => [
'customer' => 'Firmenname des Kunden',
'contact' => 'Ansprechpartner, wenn hinterlegt — sonst der Firmenname',
'email' => 'E-Mail-Adresse des Kunden',
'plan' => 'Gebuchtes Paket (leer, wenn kein laufender Vertrag)',
'amount' => 'Monatsbetrag brutto aus dem Vertrag',
'instance' => 'Adresse der Cloud des Kunden',
'operator' => 'Ihr Name — der angemeldete Betreiber',
'company' => 'Ihr Firmenname aus den Firmendaten',
],
];

View File

@ -13,6 +13,7 @@ return [
'nav' => [
'inbox' => 'Inbox',
'mail_log' => 'Mail sent',
'templates' => 'Mail templates',
'overview' => 'Overview',
'customers' => 'Customers',
'instances' => 'Instances',

View File

@ -1,21 +1,80 @@
<?php
// One customer, everything about them, and a box to write to them.
// One customer: who they are, what they bought, what they paid, what was said —
// and, last, the tab for writing to them.
return [
'back' => 'All customers',
'contract' => 'Contract',
'plan' => 'Package',
'instance' => 'Instance',
'since' => 'Customer since',
'vat_id' => 'VAT ID',
'write_invoice' => 'Write an invoice',
'tab' => [
'overview' => 'Details',
'package' => 'Package',
'billing' => 'Payments',
'messages' => 'Messages',
'compose' => 'Write',
],
// Details
'identity' => 'Customer',
'company' => 'Company',
'contact' => 'Contact',
'email' => 'Mail',
'phone' => 'Phone',
'since' => 'Customer since',
'locale' => 'Language',
'tax' => 'Tax treatment',
'type' => 'Customer type',
'type_consumer' => 'Consumer',
'type_business' => 'Business',
'type_unknown' => 'Not recorded — treated as a consumer',
'vat_id' => 'VAT ID',
'verified' => 'verified',
'unverified' => 'unverified',
'stripe' => 'Stripe customer',
'address' => 'Billing address',
'no_address' => 'No billing address on record.',
// Package
'no_contract' => 'This customer has no contract — neither running nor ended.',
'price' => 'Price',
'net_per' => 'net :term',
'term_monthly' => 'per month',
'term_yearly' => 'per year',
'quota' => 'Storage',
'traffic' => 'Traffic',
'seats' => 'Users',
'started' => 'Started',
'period_end' => 'Runs until',
'cancelled' => 'Cancelled on :date.',
'addons' => 'Modules',
'addon_until' => '— runs until :date',
'machine' => 'Machine',
'no_instance' => 'No instance yet.',
'address_web' => 'Address',
'instance_status' => 'State',
'host' => 'Host',
'datacenter' => 'Datacenter',
'disk' => 'Allocated',
// Payments
'invoices' => 'Invoices',
'no_invoices' => 'No invoice has been issued for this customer yet.',
'write_invoice' => 'Write an invoice',
'number' => 'Number',
'issued' => 'Date',
'net' => 'Net',
'gross' => 'Gross',
'orders' => 'Orders',
'no_orders' => 'No order on record.',
// Messages
'requests' => 'Requests from the portal',
'no_requests' => 'No request so far.',
'answer' => 'Answer',
'inbound' => 'Mail from the customer',
'no_inbound' => 'No mail has arrived from this customer.',
'to_inbox' => 'Inbox',
'mails' => 'Mail sent',
'no_mails' => 'Nothing has been sent to this customer yet.',
'all_mails' => 'All mail',
'all_mails' => 'Mail register',
'by_hand' => 'By hand',
];

View File

@ -17,4 +17,9 @@ return [
'mail_heading' => 'A message from CluPilot',
'mail_greeting' => 'Hello :name,',
'templates' => 'Templates',
'templates_note' => 'Insert, adjust, send. This customer\'s own details are filled in on the way.',
'no_templates' => 'No template yet. Under "Mail templates" you write down the answers you give over and over, once.',
'manage_templates' => 'Templates',
];

47
lang/en/templates.php Normal file
View File

@ -0,0 +1,47 @@
<?php
// The answers you give over and over — written once.
return [
'title' => 'Mail templates',
'subtitle' => 'The answers you give over and over, written once. A template is inserted into the compose field — with that customer\'s own details already filled in — and edited there. What goes out is never the template but what you read and changed.',
'list' => 'Existing templates',
'empty' => 'No template yet.',
'new' => 'New template',
'create' => 'Create template',
'created' => 'Template created.',
'saved' => 'Template saved.',
'edit' => 'Edit',
'edit_title' => 'Edit template',
'edit_body' => 'Changes apply to future mail. What has already been sent stays as it was.',
'save' => 'Save',
'cancel' => 'Cancel',
'active' => 'Offered when writing to a customer',
'retire' => 'Retire',
'restore' => 'Offer again',
'retired' => 'Retired',
'up' => 'Move up',
'down' => 'Move down',
'name' => 'Name',
'name_hint' => 'e.g. Data migration — quote',
'subject' => 'Subject',
'subject_hint' => 'e.g. Your enquiry about migrating data',
'body' => 'Text',
'body_hint' => "Hello {{contact}},\n\nthank you for your enquiry about {{plan}}.\n\n\n\nKind regards\n{{operator}}\n{{company}}",
'placeholders' => 'Placeholders',
'placeholders_note' => 'These words are replaced with the customer\'s own details when the template is inserted. Double curly braces; spaces inside them are fine.',
'unknown_note' => 'A typo is not replaced — it stays visible in the text, so you see it before sending. A placeholder with no value for this customer comes out empty; nothing is invented.',
'token' => [
'customer' => 'The customer\'s company name',
'contact' => 'The contact person where one is on record — the company name otherwise',
'email' => 'The customer\'s mail address',
'plan' => 'The package they are on (empty when there is no running contract)',
'amount' => 'The contract\'s monthly figure, gross',
'instance' => 'The address of the customer\'s cloud',
'operator' => 'Your name — the signed-in operator',
'company' => 'Your company name from the company details',
],
];

View File

@ -1,58 +1,287 @@
<div class="mx-auto max-w-[1120px] space-y-5">
<div class="flex flex-wrap items-start gap-4 animate-rise">
<div class="min-w-0">
<a href="{{ route('admin.customers') }}" wire:navigate class="text-xs font-semibold text-muted hover:text-ink">
{{ __('customer_detail.back') }}
</a>
<h1 class="mt-1 text-2xl font-bold tracking-tight text-ink">{{ $customer->name }}</h1>
<p class="mt-1 font-mono text-sm text-muted">{{ $customer->email }}</p>
@php
$money = fn (?int $cents, string $currency = 'EUR') => $cents === null
? '—'
: Number::currency($cents / 100, in: $currency, locale: app()->getLocale());
$date = fn ($value) => $value === null ? '—' : $value->local()->isoFormat('D. MMM YYYY');
@endphp
<div class="mx-auto max-w-[1120px] space-y-6">
{{-- ── Who, at a glance ─────────────────────────────────────────────────
The four things somebody on the phone needs before they say anything:
who it is, whether the account is live, what they are on, and whether
the machine is running. --}}
<div class="animate-rise">
<a href="{{ route('admin.customers') }}" wire:navigate class="text-xs font-semibold text-muted hover:text-ink">
{{ __('customer_detail.back') }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-2">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ $customer->name }}</h1>
<x-ui.badge :status="$customer->status === 'active' ? 'active' : 'warning'">
{{ __('admin.status.'.$customer->status) }}
</x-ui.badge>
@if ($subscription)
<x-ui.badge status="provisioning">{{ __('billing.plan.'.$subscription->plan) }}</x-ui.badge>
@endif
<a href="mailto:{{ $customer->email }}" class="ml-auto font-mono text-sm text-muted hover:text-ink">{{ $customer->email }}</a>
</div>
<x-ui.badge :status="$customer->status === 'active' ? 'active' : 'warning'" class="mt-6">
{{-- admin.status.*, not customers.status.*: there is no customers
lang file, and a missing key renders as the key itself which
is what "customers.status.active" on the page was. --}}
{{ __('admin.status.'.$customer->status) }}
</x-ui.badge>
</div>
<div class="grid gap-5 lg:grid-cols-[1fr_340px] lg:items-start">
<div class="space-y-5">
{{-- ── 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. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('customer_message.title') }}</h2>
@if ($answering)
<x-ui.badge status="provisioning">{{ __('customer_message.answering') }}</x-ui.badge>
<button type="button" wire:click="cancelAnswer" class="text-xs font-semibold text-muted hover:text-ink">
{{ __('customer_message.cancel_answer') }}
</button>
{{-- ── The tabs ─────────────────────────────────────────────────────────
Four different questions, and stacking them made a page nobody read to
the bottom. Writing is last, because it is what you do after reading.
The choice is in the query string, so a reload, a bookmark and the back
button all land where the operator was. --}}
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 border-b border-line animate-rise [animation-delay:40ms]" role="tablist">
@foreach ($tabs as $name)
<button type="button" role="tab" wire:click="$set('tab', '{{ $name }}')"
aria-selected="{{ $tab === $name ? 'true' : 'false' }}"
@class([
'flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
'border-accent-active text-ink' => $tab === $name,
'border-transparent text-muted hover:text-ink' => $tab !== $name,
])>
<x-ui.icon :name="['overview' => 'users', 'package' => 'box', 'billing' => 'receipt', 'messages' => 'mail', 'compose' => 'send'][$name] ?? 'settings'"
class="size-4" />{{ __('customer_detail.tab.'.$name) }}
@if ($name === 'messages' && $openRequests > 0)
<span class="rounded-pill bg-warning-bg px-1.5 text-[10px] font-semibold text-warning">{{ $openRequests }}</span>
@endif
</button>
@endforeach
</div>
{{-- ══ Stammdaten ═══════════════════════════════════════════════════════ --}}
@if ($tab === 'overview')
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('customer_detail.identity') }}</h2>
<dl class="mt-4 space-y-3 text-sm">
@foreach ([
__('customer_detail.company') => $customer->name,
__('customer_detail.contact') => $customer->contact_name ?: '—',
__('customer_detail.email') => $customer->email,
__('customer_detail.phone') => $customer->phone ?: '—',
__('customer_detail.since') => $date($customer->created_at),
__('customer_detail.locale') => strtoupper((string) ($customer->locale ?: '—')),
] as $term => $value)
<div class="flex justify-between gap-4 border-b border-line pb-3 last:border-0 last:pb-0">
<dt class="text-muted">{{ $term }}</dt>
<dd class="text-right font-medium text-ink">{{ $value }}</dd>
</div>
@endforeach
</dl>
</div>
<div class="space-y-5">
{{-- Whose money it is, in the tax sense. Consumer or business is
not cosmetic: it decides whether a withdrawal right exists
at all, and "nobody asked" is treated as consumer. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('customer_detail.tax') }}</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-muted">{{ __('customer_detail.type') }}</dt>
<dd class="text-right font-medium text-ink">
@if ($customer->hasRecordedType())
{{ __('customer_detail.type_'.$customer->customer_type) }}
@else
<span class="text-warning">{{ __('customer_detail.type_unknown') }}</span>
@endif
</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-muted">{{ __('customer_detail.vat_id') }}</dt>
<dd class="text-right font-mono text-ink">
{{ $customer->vat_id ?: '—' }}
@if ($customer->vat_id && $customer->hasVerifiedVatId())
<x-ui.badge status="active" class="ml-1">{{ __('customer_detail.verified') }}</x-ui.badge>
@elseif ($customer->vat_id)
<x-ui.badge status="warning" class="ml-1">{{ __('customer_detail.unverified') }}</x-ui.badge>
@endif
</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-muted">{{ __('customer_detail.stripe') }}</dt>
<dd class="text-right font-mono text-xs text-muted">{{ $customer->stripe_customer_id ?: '—' }}</dd>
</div>
</dl>
</div>
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('customer_detail.address') }}</h2>
<p class="mt-3 whitespace-pre-line text-sm leading-relaxed text-body">
{{ $customer->billing_address ?: __('customer_detail.no_address') }}
</p>
</div>
</div>
</div>
@endif
{{-- ══ Paket & Maschine ═════════════════════════════════════════════════ --}}
@if ($tab === 'package')
@if ($subscription === null)
<x-ui.alert variant="info">{{ __('customer_detail.no_contract') }}</x-ui.alert>
@else
<div class="grid gap-5 lg:grid-cols-2 lg:items-start">
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('billing.plan.'.$subscription->plan) }}</h2>
<x-ui.badge :status="$subscription->status === 'active' ? 'active' : 'warning'">
{{ $subscription->status }}
</x-ui.badge>
</div>
{{-- The contract's OWN frozen figures, not today's catalogue:
this is what the customer is owed, and a price rise does
not reach back into it. --}}
<dl class="mt-4 space-y-3 text-sm">
@foreach ([
__('customer_detail.price') => $money($subscription->price_cents, $subscription->currency).' '.__('customer_detail.net_per', ['term' => __('customer_detail.term_'.$subscription->term)]),
__('customer_detail.quota') => $subscription->quota_gb.' GB',
__('customer_detail.traffic') => $subscription->traffic_gb.' GB',
__('customer_detail.seats') => $subscription->seats,
__('customer_detail.started') => $date($subscription->started_at),
__('customer_detail.period_end') => $date($subscription->current_period_end),
] as $term => $value)
<div class="flex justify-between gap-4 border-b border-line pb-3 last:border-0 last:pb-0">
<dt class="text-muted">{{ $term }}</dt>
<dd class="text-right font-medium text-ink">{{ $value }}</dd>
</div>
@endforeach
</dl>
@if ($subscription->cancelled_at)
<x-ui.alert variant="warning" class="mt-4">
{{ __('customer_detail.cancelled', ['date' => $date($subscription->cancelled_at)]) }}
</x-ui.alert>
@endif
</div>
<form wire:submit="send" class="space-y-4">
<x-ui.input name="subject" wire:model="subject" :label="__('customer_message.subject')" />
<div>
<label for="body" class="block text-sm font-medium text-body">{{ __('customer_message.body') }}</label>
<textarea id="body" rows="7" wire:model="body"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint"
placeholder="{{ __('customer_message.body_hint') }}"></textarea>
@error('body')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
<div class="space-y-5">
@if ($subscription->addons->isNotEmpty())
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('customer_detail.addons') }}</h2>
<ul class="mt-3 divide-y divide-line text-sm">
@foreach ($subscription->addons as $addon)
{{-- addon_key and price_cents are the column
names; the price is the one it was
BOOKED at, which is what the customer
agreed to and what the row freezes. --}}
<li class="flex flex-wrap items-baseline justify-between gap-3 py-2.5">
<span class="text-body">
{{ __('billing.addon.'.$addon->addon_key.'.name') }}
@if ($addon->cancels_at)
<span class="text-xs text-warning">
{{ __('customer_detail.addon_until', ['date' => $date($addon->cancels_at)]) }}
</span>
@endif
</span>
<span class="font-mono tabular-nums text-ink">
{{ $addon->quantity > 1 ? $addon->quantity.' × ' : '' }}{{ $money($addon->price_cents, $subscription->currency) }}
</span>
</li>
@endforeach
</ul>
</div>
@endif
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('customer_detail.machine') }}</h2>
@if ($instance === null)
<p class="mt-3 text-sm text-muted">{{ __('customer_detail.no_instance') }}</p>
@else
<dl class="mt-4 space-y-3 text-sm">
@foreach ([
__('customer_detail.address_web') => $instance->subdomain ?: '—',
__('customer_detail.instance_status') => __('admin.status.'.$instance->status),
__('customer_detail.host') => $instance->host?->name ?? '—',
__('customer_detail.datacenter') => $instance->host?->datacenter ?? '—',
__('customer_detail.disk') => $instance->disk_gb.' GB',
] as $term => $value)
<div class="flex justify-between gap-4 border-b border-line pb-3 last:border-0 last:pb-0">
<dt class="text-muted">{{ $term }}</dt>
<dd class="text-right font-mono text-ink">{{ $value }}</dd>
</div>
@endforeach
</dl>
@endif
</div>
<div class="flex flex-wrap items-center gap-3">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="send">
<x-ui.icon name="send" class="size-4" />{{ __('customer_message.send') }}
</x-ui.button>
<p class="text-xs text-muted">{{ __('customer_message.note', ['email' => $customer->email]) }}</p>
</div>
</form>
</div>
</div>
@endif
@endif
{{-- ══ Zahlungen ════════════════════════════════════════════════════════ --}}
@if ($tab === 'billing')
<div class="space-y-5">
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
<div class="flex flex-wrap items-center gap-3 border-b border-line px-6 py-4">
<h2 class="font-semibold text-ink">{{ __('customer_detail.invoices') }}</h2>
<x-ui.button :href="route('admin.invoices.new', ['customer' => $customer->uuid])"
variant="secondary" size="sm" class="ml-auto" wire:navigate>
<x-ui.icon name="receipt" class="size-4" />{{ __('customer_detail.write_invoice') }}
</x-ui.button>
</div>
@if ($invoices->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('customer_detail.no_invoices') }}</p>
@else
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-6 py-3 font-semibold">{{ __('customer_detail.number') }}</th>
<th class="px-6 py-3 font-semibold">{{ __('customer_detail.issued') }}</th>
<th class="px-6 py-3 text-right font-semibold">{{ __('customer_detail.net') }}</th>
<th class="px-6 py-3 text-right font-semibold">{{ __('customer_detail.gross') }}</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody>
@foreach ($invoices as $invoice)
<tr class="border-b border-line last:border-0">
<td class="px-6 py-3 font-mono text-ink">{{ $invoice->number }}</td>
<td class="px-6 py-3 text-muted">{{ $date($invoice->issued_on) }}</td>
<td class="px-6 py-3 text-right font-mono tabular-nums text-body">{{ $money($invoice->net_cents, $invoice->currency) }}</td>
<td class="px-6 py-3 text-right font-mono tabular-nums font-semibold text-ink">{{ $money($invoice->gross_cents, $invoice->currency) }}</td>
<td class="px-6 py-3 text-right">
<a href="{{ route('admin.invoices.pdf', $invoice->uuid) }}"
class="text-xs font-semibold text-accent-text hover:underline">PDF</a>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
{{-- ── What they asked ──────────────────────────────────────── --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
{{-- What they actually bought, as opposed to what was invoiced. The
two answer different questions: an order is a purchase, an
invoice is a document about one. --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="border-b border-line px-6 py-4 font-semibold text-ink">{{ __('customer_detail.orders') }}</h2>
@if ($orders->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('customer_detail.no_orders') }}</p>
@else
<ul class="divide-y divide-line">
@foreach ($orders as $order)
<li class="flex flex-wrap items-baseline gap-x-4 gap-y-1 px-6 py-3 text-sm">
<span class="font-medium text-ink">{{ $order->label() }}</span>
<x-ui.badge :status="$order->status === 'paid' ? 'active' : 'warning'">{{ $order->status }}</x-ui.badge>
<span class="font-mono text-xs text-muted">{{ $date($order->created_at) }}</span>
<span class="ml-auto font-mono tabular-nums text-ink">{{ $money($order->amount_cents, $order->currency) }}</span>
</li>
@endforeach
</ul>
@endif
</div>
</div>
@endif
{{-- ══ Nachrichten ══════════════════════════════════════════════════════ --}}
@if ($tab === 'messages')
<div class="space-y-5">
{{-- What they asked through the portal --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('customer_detail.requests') }}</h2>
@if ($requests->isEmpty())
@ -64,15 +293,10 @@
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="font-medium text-ink">{{ $request->subject }}</span>
<x-ui.badge :status="$request->isOpen() ? 'warning' : 'active'">
{{-- Flat keys (status_open, ), not a
nested status.* group. --}}
{{ __('support.status_'.$request->status) }}
</x-ui.badge>
<span class="ml-auto text-xs text-muted">{{ $request->created_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}</span>
</div>
{{-- The customer's own words, whole. Reading the
question was the other reason to leave the
console. --}}
<p class="mt-2 whitespace-pre-line text-sm leading-relaxed text-body">{{ $request->body }}</p>
@if ($request->isOpen())
<x-ui.button wire:click="answer('{{ $request->uuid }}')" variant="secondary" size="sm" class="mt-3">
@ -85,9 +309,36 @@
@endif
</div>
{{-- ── What we sent ─────────────────────────────────────────────
The delivery register, filtered to this customer: when, which
mail, and where an operator wrote it what it said. --}}
{{-- What they wrote by mail --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('customer_detail.inbound') }}</h2>
<a href="{{ route('admin.inbox') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">
{{ __('customer_detail.to_inbox') }}
</a>
</div>
@if ($inbound->isEmpty())
<p class="mt-3 text-sm text-muted">{{ __('customer_detail.no_inbound') }}</p>
@else
<ul class="mt-3 divide-y divide-line">
@foreach ($inbound as $mail)
<li class="py-3">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span class="font-medium text-ink">{{ $mail->subject ?: __('inbox.no_subject') }}</span>
@if ($mail->hasAttachments())
<x-ui.badge status="warning">{{ __('inbox.attachments') }}: {{ count($mail->attachments) }}</x-ui.badge>
@endif
<span class="ml-auto font-mono text-xs text-muted">{{ $mail->received_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}</span>
</div>
<p class="mt-1.5 whitespace-pre-line text-xs leading-relaxed text-body">{{ $mail->body }}</p>
</li>
@endforeach
</ul>
@endif
</div>
{{-- What we sent --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('customer_detail.mails') }}</h2>
@ -122,30 +373,66 @@
@endif
</div>
</div>
@endif
{{-- ── Who they are ─────────────────────────────────────────────── --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('customer_detail.contract') }}</h2>
{{-- ══ Schreiben ════════════════════════════════════════════════════════ --}}
@if ($tab === 'compose')
<div class="grid gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('customer_message.title') }}</h2>
@if ($answering)
<x-ui.badge status="provisioning">{{ __('customer_message.answering') }}</x-ui.badge>
<button type="button" wire:click="cancelAnswer" class="text-xs font-semibold text-muted hover:text-ink">
{{ __('customer_message.cancel_answer') }}
</button>
@endif
</div>
<dl class="space-y-2.5 text-sm">
@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)
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $term }}</dt>
<dd class="text-right font-medium text-ink">{{ $value }}</dd>
<form wire:submit="send" class="space-y-4">
<x-ui.input name="subject" wire:model="subject" :label="__('customer_message.subject')" />
<div>
<label for="body" class="block text-sm font-medium text-body">{{ __('customer_message.body') }}</label>
<textarea id="body" rows="12" wire:model="body"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint"
placeholder="{{ __('customer_message.body_hint') }}"></textarea>
@error('body')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
@endforeach
</dl>
<div class="flex flex-wrap items-center gap-3">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="send">
<x-ui.icon name="send" class="size-4" />{{ __('customer_message.send') }}
</x-ui.button>
<p class="text-xs text-muted">{{ __('customer_message.note', ['email' => $customer->email]) }}</p>
</div>
</form>
</div>
{{-- Prefilled with this customer, so writing an invoice for the work
just agreed is one click from the conversation about it. --}}
<x-ui.button :href="route('admin.invoices.new', ['customer' => $customer->uuid])" variant="secondary" class="w-full" wire:navigate>
<x-ui.icon name="receipt" class="size-4" />{{ __('customer_detail.write_invoice') }}
</x-ui.button>
{{-- The templates. Inserted with this customer's own details already
in them; the last two sentences stay the operator's to write. --}}
<div class="space-y-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms] lg:sticky lg:top-6">
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-ink">{{ __('customer_message.templates') }}</h2>
<a href="{{ route('admin.templates') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">
{{ __('customer_message.manage_templates') }}
</a>
</div>
@if ($templates->isEmpty())
<p class="text-xs leading-relaxed text-muted">{{ __('customer_message.no_templates') }}</p>
@else
<p class="text-xs leading-relaxed text-muted">{{ __('customer_message.templates_note') }}</p>
<ul class="space-y-1.5">
@foreach ($templates as $template)
<li>
<button type="button" wire:click="useTemplate('{{ $template->uuid }}')"
class="w-full rounded-md border border-line bg-surface-2 px-3 py-2 text-left text-sm text-body transition-colors hover:border-accent-border hover:text-ink">
{{ $template->name }}
</button>
</li>
@endforeach
</ul>
@endif
</div>
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,43 @@
<div class="p-6">
<h2 class="text-lg font-semibold text-ink">{{ __('templates.edit_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('templates.edit_body') }}</p>
<form wire:submit="save" class="mt-5 space-y-4">
<x-ui.input name="name" wire:model="name" :label="__('templates.name')" />
<x-ui.input name="subject" wire:model="subject" :label="__('templates.subject')" />
<div>
<label for="edit-body" class="block text-sm font-medium text-body">{{ __('templates.body') }}</label>
<textarea id="edit-body" rows="10" wire:model="body"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink"></textarea>
@error('body')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
{{-- One checkbox, one value: retiring an answer rather than deleting it,
because it is still the answer somebody was given last year. --}}
<label class="flex items-center gap-2.5 text-sm text-body">
<input type="checkbox" wire:model="active"
class="size-4 rounded border-line-strong text-accent-active focus:ring-accent-active" />
{{ __('templates.active') }}
</label>
<div class="rounded-md border border-line bg-surface-2 p-3">
<p class="lbl">{{ __('templates.placeholders') }}</p>
<p class="mt-2 flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs text-accent-text">
@foreach ($placeholders as $token => $explanation)
{{-- See mail-templates.blade.php: a literal }} inside an
echo tag ends the tag. --}}
@php $literal = '{{'.$token.'}}'; @endphp
<span class="select-all">{{ $literal }}</span>
@endforeach
</p>
</div>
<div class="flex flex-wrap gap-2 border-t border-line pt-4">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
{{ __('templates.save') }}
</x-ui.button>
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('templates.cancel') }}</x-ui.button>
</div>
</form>
</div>

View File

@ -0,0 +1,110 @@
<div class="mx-auto max-w-[1120px] space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('templates.title') }}</h1>
<p class="mt-1 max-w-[75ch] text-sm text-muted">{{ __('templates.subtitle') }}</p>
</div>
<div class="grid gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="space-y-5">
{{-- The list. Order is the operator's own, because the order they
think in is neither alphabetical nor the order these happened to
be written in. --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
<h2 class="border-b border-line px-6 py-4 font-semibold text-ink">{{ __('templates.list') }}</h2>
@if ($templates->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('templates.empty') }}</p>
@else
<ul class="divide-y divide-line">
@foreach ($templates as $template)
<li wire:key="tpl-{{ $template->uuid }}" class="px-6 py-4">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span @class(['font-medium', 'text-ink' => $template->active, 'text-muted line-through' => ! $template->active])>
{{ $template->name }}
</span>
@if (! $template->active)
<x-ui.badge status="warning">{{ __('templates.retired') }}</x-ui.badge>
@endif
<span class="ml-auto flex items-center gap-1">
{{-- Two clicks, one value each, no height
change: R20's exception. --}}
<button type="button" wire:click="move('{{ $template->uuid }}', -15)"
class="rounded p-1.5 text-muted hover:text-ink" aria-label="{{ __('templates.up') }}">
<x-ui.icon name="chevron-down" class="size-4 rotate-180" />
</button>
<button type="button" wire:click="move('{{ $template->uuid }}', 15)"
class="rounded p-1.5 text-muted hover:text-ink" aria-label="{{ __('templates.down') }}">
<x-ui.icon name="chevron-down" class="size-4" />
</button>
</span>
</div>
<p class="mt-1 text-sm text-body">{{ $template->subject }}</p>
<p class="mt-1.5 line-clamp-2 text-xs leading-relaxed text-muted">{{ $template->body }}</p>
<div class="mt-3 flex flex-wrap gap-2">
{{-- R20: editing opens a modal. A five-line
textarea in a list row is the height jump
the rule exists to stop. --}}
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.edit-mail-template', arguments: { uuid: '{{ $template->uuid }}' } })">
<x-ui.icon name="pencil" class="size-4" />{{ __('templates.edit') }}
</x-ui.button>
<x-ui.button variant="ghost" size="sm" wire:click="toggleActive('{{ $template->uuid }}')">
{{ $template->active ? __('templates.retire') : __('templates.restore') }}
</x-ui.button>
</div>
</li>
@endforeach
</ul>
@endif
</div>
{{-- Creating. A form that IS the page rather than a modal R20
exempts exactly this, because nothing is being edited in place. --}}
<form wire:submit="create" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('templates.new') }}</h2>
<div class="grid gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('templates.name')" :placeholder="__('templates.name_hint')" />
<x-ui.input name="subject" wire:model="subject" :label="__('templates.subject')" :placeholder="__('templates.subject_hint')" />
</div>
<div>
<label for="body" class="block text-sm font-medium text-body">{{ __('templates.body') }}</label>
<textarea id="body" rows="8" wire:model="body"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint"
placeholder="{{ __('templates.body_hint') }}"></textarea>
@error('body')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="create">
<x-ui.icon name="plus" class="size-4" />{{ __('templates.create') }}
</x-ui.button>
</form>
</div>
{{-- The placeholders, rendered from the renderer's own list so a token
added there appears here without anybody writing it down twice. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms] lg:sticky lg:top-6">
<h2 class="font-semibold text-ink">{{ __('templates.placeholders') }}</h2>
<p class="text-xs leading-relaxed text-muted">{{ __('templates.placeholders_note') }}</p>
<dl class="space-y-3">
@foreach ($placeholders as $token => $explanation)
{{-- Assembled in PHP, not inline: Blade's echo tag ends at
the first }} it finds, so a literal one inside the
expression cuts the tag in half and compiles to
nonsense. Caught by the test that renders this page. --}}
@php $literal = '{{'.$token.'}}'; @endphp
<div>
<dt class="select-all font-mono text-xs font-semibold text-accent-text">{{ $literal }}</dt>
<dd class="mt-0.5 text-xs leading-relaxed text-muted">{{ __($explanation) }}</dd>
</div>
@endforeach
</dl>
<p class="border-t border-line pt-3 text-xs leading-relaxed text-muted">{{ __('templates.unknown_note') }}</p>
</div>
</div>
</div>

View File

@ -45,6 +45,9 @@ Route::get('/finance', Admin\Finance::class)->name('finance');
Route::get('/customers/{uuid}', Admin\CustomerDetail::class)->name('customer');
// What customers wrote to us — see Admin\Inbox.
Route::get('/inbox', Admin\Inbox::class)->name('inbox');
// The answers an operator gives over and over, written once — see
// Admin\MailTemplates.
Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates');
// The register of what left the building — see Admin\MailLog.
Route::get('/mail-log', Admin\MailLog::class)->name('mail-log');
Route::get('/invoices', Admin\Invoices::class)->name('invoices');

View File

@ -0,0 +1,168 @@
<?php
use App\Livewire\Admin\CustomerDetail;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\Order;
use App\Models\Subscription;
use App\Support\CompanyProfile;
use Livewire\Livewire;
/**
* The customer page shows the customer.
*
* The first version was a compose box with three lists under it, and the
* complaint was exactly right: it showed nothing ABOUT the customer. An operator
* on the phone needs the address, the package, whether the last invoice was paid
* and what was already said and none of that was on the page.
*/
beforeEach(function () {
CompanyProfile::put([
'name' => 'CluPilot GmbH', 'address' => 'Musterstraße 1',
'postcode' => '1010', 'city' => 'Wien', 'vat_id' => 'ATU12345678',
]);
$this->customer = Customer::factory()->create([
'name' => 'Beispiel GmbH',
'contact_name' => 'Bea Berger',
'phone' => '+43 1 2345678',
'billing_address' => "Musterstraße 2\n1020 Wien",
'vat_id' => 'ATU99999999',
'customer_type' => Customer::TYPE_BUSINESS,
]);
});
it('opens on the details, and keeps that tab out of the address bar', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSet('tab', 'overview')
// The things somebody on the phone needs first.
->assertSee('Bea Berger')
->assertSee('+43 1 2345678')
->assertSee('ATU99999999')
->assertSee('1020 Wien')
->assertSee(__('customer_detail.type_business'));
});
it('remembers the tab across a reload, because it is in the URL', function () {
Livewire::withQueryParams(['tab' => 'billing'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSet('tab', 'billing')
->assertSee(__('customer_detail.invoices'));
});
it('falls back to the first tab when the URL names one that does not exist', function () {
Livewire::withQueryParams(['tab' => 'wat'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSet('tab', 'overview');
});
it('says a customer nobody asked is treated as a consumer', function () {
// Not cosmetic: it decides whether a withdrawal right exists at all, and the
// page must not let "nobody asked" read as "business".
$unknown = Customer::factory()->create(['customer_type' => null]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $unknown->uuid])
->assertSee(__('customer_detail.type_unknown'));
});
it('shows the contract the customer is owed, with its own frozen figures', function () {
// The contract's own price, not today's catalogue: a price rise does not
// reach back into what somebody already bought.
Subscription::factory()->plan('team')->create([
'customer_id' => $this->customer->id,
'status' => 'active',
]);
Livewire::withQueryParams(['tab' => 'package'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSee(__('billing.plan.team'))
->assertSee('500 GB') // team's quota
->assertSee(__('customer_detail.price'));
});
it('shows the machine, and says so plainly when there is none', function () {
Livewire::withQueryParams(['tab' => 'package'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSee(__('customer_detail.no_contract'));
Subscription::factory()->plan('start')->create(['customer_id' => $this->customer->id, 'status' => 'active']);
Instance::factory()->create([
'customer_id' => $this->customer->id,
'subdomain' => 'kanzlei-berger',
'status' => 'active',
]);
Livewire::withQueryParams(['tab' => 'package'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSee('kanzlei-berger');
});
it('shows what was invoiced and what was bought, which are two questions', function () {
// An order is a purchase; an invoice is a document about one. A customer
// asking "was das schon verrechnet?" is asking about the second.
InvoiceSeries::query()->firstOrCreate(
['kind' => 'invoice'],
['name' => 'Rechnungen', 'prefix' => 'RE', 'active' => true],
);
Order::factory()->create([
'customer_id' => $this->customer->id,
'plan' => 'start',
'amount_cents' => 4900,
'status' => 'paid',
]);
Invoice::create([
'invoice_series_id' => InvoiceSeries::query()->first()->id,
'customer_id' => $this->customer->id,
'number' => 'RE-2026-0007',
'number_year' => 2026,
'number_sequence' => 7,
'issued_on' => now()->toDateString(),
'due_on' => now()->addDays(14)->toDateString(),
'snapshot' => ['meta' => ['number' => 'RE-2026-0007']],
'net_cents' => 4900,
'tax_cents' => 980,
'gross_cents' => 5880,
'currency' => 'EUR',
]);
Livewire::withQueryParams(['tab' => 'billing'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid])
->assertSee('RE-2026-0007')
->assertSee('58,80')
->assertSee(__('customer_detail.orders'));
});
it('offers writing last, because it is what you do after reading', function () {
$tabs = CustomerDetail::TABS;
expect($tabs[0])->toBe('overview')
->and(end($tabs))->toBe('compose');
// Every tab has a label, or the bar reads "customer_detail.tab.x".
foreach ($tabs as $tab) {
expect(__('customer_detail.tab.'.$tab))->not->toBe('customer_detail.tab.'.$tab);
}
});
it('does not print a lang key at anybody', function () {
// The badge read "customers.status.active" once. A key with no file behind
// it renders as itself and Laravel says nothing about it.
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $this->customer->uuid]);
$page->assertDontSee('customers.status.')
->assertDontSee('customer_detail.tab.')
->assertSee(__('admin.status.active'));
});

View File

@ -149,7 +149,10 @@ it('shows the customer their own words, so nobody opens the mail client to read
'reported_by' => $customer->email,
]);
Livewire::actingAs(operator('Owner'), 'operator')
// On the messages tab since the page was rebuilt around tabs: the details
// are the first thing an operator needs, the conversation the second.
Livewire::withQueryParams(['tab' => 'messages'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->assertSee('Wir haben 42 GB in ownCloud und wollen wechseln.');
});
@ -224,7 +227,8 @@ it('shows one customer their own things and never another customer', function ()
SentMail::create(['customer_id' => $one->id, 'to' => $one->email, 'subject' => 'Post an die Erste', 'sent_at' => now()]);
SentMail::create(['customer_id' => $two->id, 'to' => $two->email, 'subject' => 'Post an die Zweite', 'sent_at' => now()]);
Livewire::actingAs(operator('Owner'), 'operator')
Livewire::withQueryParams(['tab' => 'messages'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $one->uuid])
->assertSee('Frage der Ersten')
->assertSee('Post an die Erste')
@ -241,7 +245,10 @@ it('names the status in words rather than printing the key at it', function () {
'body' => 'Text', 'status' => 'open', 'reported_by' => $customer->email,
]);
Livewire::actingAs(operator('Owner'), 'operator')
// The status badge is in the page header, on every tab; the request status
// lives with the requests.
Livewire::withQueryParams(['tab' => 'messages'])
->actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->assertSee(__('admin.status.active'))
->assertSee(__('support.status_open'))

View File

@ -0,0 +1,233 @@
<?php
use App\Livewire\Admin\CustomerDetail;
use App\Livewire\Admin\EditMailTemplate;
use App\Livewire\Admin\MailTemplates;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\MailTemplate;
use App\Models\SentMail;
use App\Models\Subscription;
use App\Services\Mail\MailTemplateRenderer;
use App\Support\CompanyProfile;
use Livewire\Livewire;
/**
* The answers an operator gives over and over, written once.
*
* A template is INSERTED and then edited, never sent as it stands. So the thing
* worth testing is not "did a mail go out" but "did the right details end up in
* the field the operator is about to correct".
*/
beforeEach(fn () => CompanyProfile::put(['name' => 'CluPilot GmbH']));
it('fills a template with the customer own details', function () {
$customer = Customer::factory()->create([
'name' => 'Beispiel GmbH',
'contact_name' => 'Bea Berger',
'email' => 'bea@beispiel.test',
]);
Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'status' => 'active',
]);
$template = MailTemplate::create([
'name' => 'Datenübernahme',
'subject' => 'Ihre Anfrage — {{customer}}',
'body' => "Guten Tag {{contact}},\n\nzu Ihrem Paket {{plan}} für {{amount}}:\n\n{{operator}}\n{{company}}",
]);
$filled = app(MailTemplateRenderer::class)->render($template, $customer, operator('Owner'));
expect($filled['subject'])->toBe('Ihre Anfrage — Beispiel GmbH')
->and($filled['body'])->toContain('Guten Tag Bea Berger,')
->and($filled['body'])->toContain(__('billing.plan.team'))
->and($filled['body'])->toContain('CluPilot GmbH');
});
it('writes to the person where one is on record, and to the company otherwise', function () {
// "Guten Tag Beispiel GmbH" is how a mail announces that it came out of a
// database.
$withContact = Customer::factory()->create(['name' => 'Beispiel GmbH', 'contact_name' => 'Bea Berger']);
$without = Customer::factory()->create(['name' => 'Zweite GmbH', 'contact_name' => null]);
$renderer = app(MailTemplateRenderer::class);
expect($renderer->values($withContact)['contact'])->toBe('Bea Berger')
->and($renderer->values($without)['contact'])->toBe('Zweite GmbH');
});
it('quotes the amount the customer knows, which is the gross one', function () {
// What the price sheet quoted them and what their bank statement says.
$customer = Customer::factory()->create();
Subscription::factory()->plan('start')->create([
'customer_id' => $customer->id,
'status' => 'active',
]);
$amount = app(MailTemplateRenderer::class)->values($customer)['amount'];
// 49 € net at 20 % → 58,80 €.
expect($amount)->toContain('58,80');
});
it('leaves a placeholder empty rather than inventing a package nobody bought', function () {
// A guess in a mail to a paying customer is worse than a gap the operator
// can see and fill.
$customer = Customer::factory()->create();
$values = app(MailTemplateRenderer::class)->values($customer);
expect($values['plan'])->toBe('')
->and($values['amount'])->toBe('')
->and($values['instance'])->toBe('');
});
it('leaves a typo standing so it is seen before anything is sent', function () {
// Replacing it with nothing produces "Guten Tag ," — a mail that looks sent
// by a machine, which is what templates exist to avoid.
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH']);
$template = MailTemplate::create([
'name' => 'Mit Tippfehler',
'subject' => 'Test',
'body' => 'Guten Tag {{kunde}}, für {{customer}}.',
]);
$body = app(MailTemplateRenderer::class)->render($template, $customer)['body'];
expect($body)->toContain('{{kunde}}')
->and($body)->toContain('Beispiel GmbH');
});
it('tolerates a space inside the braces, because somebody will type one', function () {
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH']);
$template = MailTemplate::create(['name' => 'X', 'subject' => '{{ customer }}', 'body' => '{{ CUSTOMER }}']);
$filled = app(MailTemplateRenderer::class)->render($template, $customer);
expect($filled['subject'])->toBe('Beispiel GmbH')
->and($filled['body'])->toBe('Beispiel GmbH');
});
it('names the address of the cloud, where there is one', function () {
$customer = Customer::factory()->create();
Instance::factory()->create([
'customer_id' => $customer->id,
'subdomain' => 'kanzlei-berger',
'status' => 'active',
]);
expect(app(MailTemplateRenderer::class)->values($customer)['instance'])->toBe('kanzlei-berger');
});
// ---- Managing them ----
it('creates a template and offers it when writing', function () {
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailTemplates::class)
->set('name', 'Datenübernahme')
->set('subject', 'Ihre Anfrage')
->set('body', 'Guten Tag {{contact}},')
->call('create')
->assertHasNoErrors();
expect(MailTemplate::query()->count())->toBe(1)
->and(MailTemplate::query()->usable()->count())->toBe(1);
});
it('retires a template instead of deleting it', function () {
// An answer that is no longer given is still the answer somebody was given
// last year, and the mail register refers to it.
$template = MailTemplate::create(['name' => 'Alt', 'subject' => 'S', 'body' => 'B']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailTemplates::class)
->call('toggleActive', $template->uuid);
expect($template->fresh()->active)->toBeFalse()
->and(MailTemplate::query()->count())->toBe(1)
->and(MailTemplate::query()->usable()->count())->toBe(0);
});
it('edits a template in a modal, never in the row', function () {
// R20: a five-line textarea growing inside a list row pushes every column
// beside it and reads as a rendering fault.
$template = MailTemplate::create(['name' => 'Alt', 'subject' => 'S', 'body' => 'B']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(EditMailTemplate::class, ['uuid' => $template->uuid])
->set('name', 'Neu')
->set('body', 'Guten Tag {{contact}},')
->call('save');
expect($template->fresh()->name)->toBe('Neu')
->and($template->fresh()->body)->toContain('{{contact}}');
});
it('keeps the templates to operators who may write to customers', function () {
Livewire::actingAs(operator('Read-only'), 'operator')
->test(MailTemplates::class)
->assertForbidden();
});
// ---- Using one on the customer page ----
it('puts a template into the compose fields and opens that tab', function () {
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH', 'contact_name' => 'Bea Berger']);
$template = MailTemplate::create([
'name' => 'Datenübernahme',
'subject' => 'Ihre Anfrage — {{customer}}',
'body' => 'Guten Tag {{contact}},',
]);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('useTemplate', $template->uuid);
expect($page->get('subject'))->toBe('Ihre Anfrage — Beispiel GmbH')
->and($page->get('body'))->toBe('Guten Tag Bea Berger,')
// Where the operator now has to be to finish it.
->and($page->get('tab'))->toBe('compose');
});
it('does not overwrite a subject the operator already typed', function () {
// A template that eats work is a template nobody uses twice.
$customer = Customer::factory()->create();
$template = MailTemplate::create(['name' => 'X', 'subject' => 'Aus der Vorlage', 'body' => 'Text']);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->set('subject', 'Selbst geschrieben')
->call('useTemplate', $template->uuid);
expect($page->get('subject'))->toBe('Selbst geschrieben')
->and($page->get('body'))->toBe('Text');
});
it('will not insert a retired template', function () {
$customer = Customer::factory()->create();
$template = MailTemplate::create(['name' => 'X', 'subject' => 'S', 'body' => 'B', 'active' => false]);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('useTemplate', $template->uuid);
expect($page->get('body'))->toBe('');
});
it('sends what the operator saw, not what the template said', function () {
// The whole point of inserting rather than sending: the last two sentences
// are always specific to the person asking.
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH']);
$template = MailTemplate::create(['name' => 'X', 'subject' => 'S', 'body' => 'Aus der Vorlage.']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('useTemplate', $template->uuid)
->set('body', 'Aus der Vorlage. Und mein eigener Satz.')
->call('send');
expect(SentMail::query()->where('customer_id', $customer->id)->latest('id')->first()->body)
->toBe('Aus der Vorlage. Und mein eigener Satz.');
});