From 33566cd404c6d15d0b4eaebf63f2746fccd553fb Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 22:37:30 +0200 Subject: [PATCH] Show the customer, and write the answers once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- app/Livewire/Admin/CustomerDetail.php | 143 ++++-- app/Livewire/Admin/EditMailTemplate.php | 73 +++ app/Livewire/Admin/MailTemplates.php | 104 +++++ app/Models/MailTemplate.php | 42 ++ app/Services/Mail/MailTemplateRenderer.php | 141 ++++++ app/Support/Navigation.php | 4 + ..._write_the_answers_once_and_reuse_them.php | 50 ++ lang/de/admin.php | 1 + lang/de/customer_detail.php | 77 ++- lang/de/customer_message.php | 5 + lang/de/templates.php | 47 ++ lang/en/admin.php | 1 + lang/en/customer_detail.php | 77 ++- lang/en/customer_message.php | 5 + lang/en/templates.php | 47 ++ .../livewire/admin/customer-detail.blade.php | 437 +++++++++++++++--- .../admin/edit-mail-template.blade.php | 43 ++ .../livewire/admin/mail-templates.blade.php | 110 +++++ routes/admin.php | 3 + .../Feature/Admin/CustomerDetailTabsTest.php | 168 +++++++ tests/Feature/Admin/MailRegisterTest.php | 13 +- tests/Feature/Admin/MailTemplateTest.php | 233 ++++++++++ 23 files changed, 1701 insertions(+), 125 deletions(-) create mode 100644 app/Livewire/Admin/EditMailTemplate.php create mode 100644 app/Livewire/Admin/MailTemplates.php create mode 100644 app/Models/MailTemplate.php create mode 100644 app/Services/Mail/MailTemplateRenderer.php create mode 100644 database/migrations/2026_07_31_100000_write_the_answers_once_and_reuse_them.php create mode 100644 lang/de/templates.php create mode 100644 lang/en/templates.php create mode 100644 resources/views/livewire/admin/edit-mail-template.blade.php create mode 100644 resources/views/livewire/admin/mail-templates.blade.php create mode 100644 tests/Feature/Admin/CustomerDetailTabsTest.php create mode 100644 tests/Feature/Admin/MailTemplateTest.php diff --git a/VERSION b/VERSION index 4f9de89..598cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.35 +1.3.36 diff --git a/app/Livewire/Admin/CustomerDetail.php b/app/Livewire/Admin/CustomerDetail.php index e84883e..76df2d5 100644 --- a/app/Livewire/Admin/CustomerDetail.php +++ b/app/Livewire/Admin/CustomerDetail.php @@ -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(); + } } diff --git a/app/Livewire/Admin/EditMailTemplate.php b/app/Livewire/Admin/EditMailTemplate.php new file mode 100644 index 0000000..a0177ce --- /dev/null +++ b/app/Livewire/Admin/EditMailTemplate.php @@ -0,0 +1,73 @@ +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, + ]); + } +} diff --git a/app/Livewire/Admin/MailTemplates.php b/app/Livewire/Admin/MailTemplates.php new file mode 100644 index 0000000..e8d4874 --- /dev/null +++ b/app/Livewire/Admin/MailTemplates.php @@ -0,0 +1,104 @@ +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, + ]); + } +} diff --git a/app/Models/MailTemplate.php b/app/Models/MailTemplate.php new file mode 100644 index 0000000..cde5cc2 --- /dev/null +++ b/app/Models/MailTemplate.php @@ -0,0 +1,42 @@ + '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'); + } +} diff --git a/app/Services/Mail/MailTemplateRenderer.php b/app/Services/Mail/MailTemplateRenderer.php new file mode 100644 index 0000000..8ca7b39 --- /dev/null +++ b/app/Services/Mail/MailTemplateRenderer.php @@ -0,0 +1,141 @@ + '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 + */ + 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 $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)); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 32ff4c0..491cfdf 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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'], diff --git a/database/migrations/2026_07_31_100000_write_the_answers_once_and_reuse_them.php b/database/migrations/2026_07_31_100000_write_the_answers_once_and_reuse_them.php new file mode 100644 index 0000000..ab6f1ef --- /dev/null +++ b/database/migrations/2026_07_31_100000_write_the_answers_once_and_reuse_them.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 7de32a8..78b708f 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -13,6 +13,7 @@ return [ 'nav' => [ 'inbox' => 'Posteingang', 'mail_log' => 'Postausgang', + 'templates' => 'E-Mail-Vorlagen', 'overview' => 'Übersicht', 'customers' => 'Kunden', 'instances' => 'Instanzen', diff --git a/lang/de/customer_detail.php b/lang/de/customer_detail.php index d1fe5db..bbdd9ec 100644 --- a/lang/de/customer_detail.php +++ b/lang/de/customer_detail.php @@ -1,21 +1,80 @@ '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', ]; diff --git a/lang/de/customer_message.php b/lang/de/customer_message.php index 1790671..be39df1 100644 --- a/lang/de/customer_message.php +++ b/lang/de/customer_message.php @@ -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', ]; diff --git a/lang/de/templates.php b/lang/de/templates.php new file mode 100644 index 0000000..7d9f892 --- /dev/null +++ b/lang/de/templates.php @@ -0,0 +1,47 @@ + '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', + ], +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 691f953..5841b5c 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -13,6 +13,7 @@ return [ 'nav' => [ 'inbox' => 'Inbox', 'mail_log' => 'Mail sent', + 'templates' => 'Mail templates', 'overview' => 'Overview', 'customers' => 'Customers', 'instances' => 'Instances', diff --git a/lang/en/customer_detail.php b/lang/en/customer_detail.php index adaee7f..e54d01d 100644 --- a/lang/en/customer_detail.php +++ b/lang/en/customer_detail.php @@ -1,21 +1,80 @@ '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', ]; diff --git a/lang/en/customer_message.php b/lang/en/customer_message.php index 9bfd448..dc53a13 100644 --- a/lang/en/customer_message.php +++ b/lang/en/customer_message.php @@ -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', ]; diff --git a/lang/en/templates.php b/lang/en/templates.php new file mode 100644 index 0000000..93ad548 --- /dev/null +++ b/lang/en/templates.php @@ -0,0 +1,47 @@ + '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', + ], +]; diff --git a/resources/views/livewire/admin/customer-detail.blade.php b/resources/views/livewire/admin/customer-detail.blade.php index b2792f7..d8b8dd0 100644 --- a/resources/views/livewire/admin/customer-detail.blade.php +++ b/resources/views/livewire/admin/customer-detail.blade.php @@ -1,58 +1,287 @@ -
-
-
- - ‹ {{ __('customer_detail.back') }} - -

{{ $customer->name }}

-

{{ $customer->email }}

+@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 + +
+ {{-- ── 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. --}} +
+ + ‹ {{ __('customer_detail.back') }} + +
+

{{ $customer->name }}

+ + {{ __('admin.status.'.$customer->status) }} + + @if ($subscription) + {{ __('billing.plan.'.$subscription->plan) }} + @endif + {{ $customer->email }}
- - {{-- 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) }} -
-
-
- {{-- ── 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') }} - + {{-- ── 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. --}} +
+ @foreach ($tabs as $name) + + @endforeach +
+ + {{-- ══ Stammdaten ═══════════════════════════════════════════════════════ --}} + @if ($tab === 'overview') +
+
+

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

+
+ @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) +
+
{{ $term }}
+
{{ $value }}
+
+ @endforeach +
+
+ +
+ {{-- 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. --}} +
+

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

+
+
+
{{ __('customer_detail.type') }}
+
+ @if ($customer->hasRecordedType()) + {{ __('customer_detail.type_'.$customer->customer_type) }} + @else + {{ __('customer_detail.type_unknown') }} + @endif +
+
+
+
{{ __('customer_detail.vat_id') }}
+
+ {{ $customer->vat_id ?: '—' }} + @if ($customer->vat_id && $customer->hasVerifiedVatId()) + {{ __('customer_detail.verified') }} + @elseif ($customer->vat_id) + {{ __('customer_detail.unverified') }} + @endif +
+
+
+
{{ __('customer_detail.stripe') }}
+
{{ $customer->stripe_customer_id ?: '—' }}
+
+
+
+ +
+

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

+

+ {{ $customer->billing_address ?: __('customer_detail.no_address') }} +

+
+
+
+ @endif + + {{-- ══ Paket & Maschine ═════════════════════════════════════════════════ --}} + @if ($tab === 'package') + @if ($subscription === null) + {{ __('customer_detail.no_contract') }} + @else +
+
+
+

{{ __('billing.plan.'.$subscription->plan) }}

+ + {{ $subscription->status }} + +
+ + {{-- 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. --}} +
+ @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) +
+
{{ $term }}
+
{{ $value }}
+
+ @endforeach +
+ + @if ($subscription->cancelled_at) + + {{ __('customer_detail.cancelled', ['date' => $date($subscription->cancelled_at)]) }} + @endif
-
- -
- - - @error('body')

{{ $message }}

@enderror +
+ @if ($subscription->addons->isNotEmpty()) +
+

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

+
    + @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. --}} +
  • + + {{ __('billing.addon.'.$addon->addon_key.'.name') }} + @if ($addon->cancels_at) + + {{ __('customer_detail.addon_until', ['date' => $date($addon->cancels_at)]) }} + + @endif + + + {{ $addon->quantity > 1 ? $addon->quantity.' × ' : '' }}{{ $money($addon->price_cents, $subscription->currency) }} + +
  • + @endforeach +
+
+ @endif + +
+

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

+ @if ($instance === null) +

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

+ @else +
+ @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) +
+
{{ $term }}
+
{{ $value }}
+
+ @endforeach +
+ @endif
-
- - {{ __('customer_message.send') }} - -

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

-
- +
+
+ @endif + @endif + + {{-- ══ Zahlungen ════════════════════════════════════════════════════════ --}} + @if ($tab === 'billing') +
+
+
+

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

+ + {{ __('customer_detail.write_invoice') }} + +
+ + @if ($invoices->isEmpty()) +

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

+ @else + + + + + + + + + + + + @foreach ($invoices as $invoice) + + + + + + + + @endforeach + +
{{ __('customer_detail.number') }}{{ __('customer_detail.issued') }}{{ __('customer_detail.net') }}{{ __('customer_detail.gross') }}
{{ $invoice->number }}{{ $date($invoice->issued_on) }}{{ $money($invoice->net_cents, $invoice->currency) }}{{ $money($invoice->gross_cents, $invoice->currency) }} + PDF +
+ @endif
- {{-- ── What they asked ──────────────────────────────────────── --}} -
+ {{-- 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. --}} +
+

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

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

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

+ @else +
    + @foreach ($orders as $order) +
  • + {{ $order->label() }} + {{ $order->status }} + {{ $date($order->created_at) }} + {{ $money($order->amount_cents, $order->currency) }} +
  • + @endforeach +
+ @endif +
+
+ @endif + + {{-- ══ Nachrichten ══════════════════════════════════════════════════════ --}} + @if ($tab === 'messages') +
+ {{-- What they asked through the portal --}} +

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

@if ($requests->isEmpty()) @@ -64,15 +293,10 @@
{{ $request->subject }} - {{-- Flat keys (status_open, …), not a - nested status.* group. --}} {{ __('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()) @@ -85,9 +309,36 @@ @endif
- {{-- ── 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 --}} +
+
+

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

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

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

+ @else +
    + @foreach ($inbound as $mail) +
  • +
    + {{ $mail->subject ?: __('inbox.no_subject') }} + @if ($mail->hasAttachments()) + {{ __('inbox.attachments') }}: {{ count($mail->attachments) }} + @endif + {{ $mail->received_at->local()->isoFormat('D. MMM YYYY, HH:mm') }} +
    +

    {{ $mail->body }}

    +
  • + @endforeach +
+ @endif +
+ + {{-- What we sent --}}

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

@@ -122,30 +373,66 @@ @endif
+ @endif - {{-- ── Who they are ─────────────────────────────────────────────── --}} -
-

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

+ {{-- ══ Schreiben ════════════════════════════════════════════════════════ --}} + @if ($tab === 'compose') +
+
+
+

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

+ @if ($answering) + {{ __('customer_message.answering') }} + + @endif +
-
- @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 }}
+
+ +
+ + + @error('body')

{{ $message }}

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

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

+
+ +
- {{-- 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') }} - + {{-- The templates. Inserted with this customer's own details already + in them; the last two sentences stay the operator's to write. --}} +
+
+

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

+ + {{ __('customer_message.manage_templates') }} › + +
+ + @if ($templates->isEmpty()) +

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

+ @else +

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

+
    + @foreach ($templates as $template) +
  • + +
  • + @endforeach +
+ @endif +
-
+ @endif
diff --git a/resources/views/livewire/admin/edit-mail-template.blade.php b/resources/views/livewire/admin/edit-mail-template.blade.php new file mode 100644 index 0000000..c7cf6b5 --- /dev/null +++ b/resources/views/livewire/admin/edit-mail-template.blade.php @@ -0,0 +1,43 @@ +
+

{{ __('templates.edit_title') }}

+

{{ __('templates.edit_body') }}

+ +
+ + + +
+ + + @error('body')

{{ $message }}

@enderror +
+ + {{-- One checkbox, one value: retiring an answer rather than deleting it, + because it is still the answer somebody was given last year. --}} + + +
+

{{ __('templates.placeholders') }}

+

+ @foreach ($placeholders as $token => $explanation) + {{-- See mail-templates.blade.php: a literal }} inside an + echo tag ends the tag. --}} + @php $literal = '{{'.$token.'}}'; @endphp + {{ $literal }} + @endforeach +

+
+ +
+ + {{ __('templates.save') }} + + {{ __('templates.cancel') }} +
+ +
diff --git a/resources/views/livewire/admin/mail-templates.blade.php b/resources/views/livewire/admin/mail-templates.blade.php new file mode 100644 index 0000000..fd29e85 --- /dev/null +++ b/resources/views/livewire/admin/mail-templates.blade.php @@ -0,0 +1,110 @@ +
+
+

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

+

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

+
+ +
+
+ {{-- 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. --}} +
+

{{ __('templates.list') }}

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

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

+ @else +
    + @foreach ($templates as $template) +
  • +
    + $template->active, 'text-muted line-through' => ! $template->active])> + {{ $template->name }} + + @if (! $template->active) + {{ __('templates.retired') }} + @endif + + {{-- Two clicks, one value each, no height + change: R20's exception. --}} + + + +
    + +

    {{ $template->subject }}

    +

    {{ $template->body }}

    + +
    + {{-- R20: editing opens a modal. A five-line + textarea in a list row is the height jump + the rule exists to stop. --}} + + {{ __('templates.edit') }} + + + {{ $template->active ? __('templates.retire') : __('templates.restore') }} + +
    +
  • + @endforeach +
+ @endif +
+ + {{-- Creating. A form that IS the page rather than a modal — R20 + exempts exactly this, because nothing is being edited in place. --}} +
+

{{ __('templates.new') }}

+ +
+ + +
+ +
+ + + @error('body')

{{ $message }}

@enderror +
+ + + {{ __('templates.create') }} + +
+
+ + {{-- The placeholders, rendered from the renderer's own list so a token + added there appears here without anybody writing it down twice. --}} +
+

{{ __('templates.placeholders') }}

+

{{ __('templates.placeholders_note') }}

+ +
+ @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 +
+
{{ $literal }}
+
{{ __($explanation) }}
+
+ @endforeach +
+ +

{{ __('templates.unknown_note') }}

+
+
+
diff --git a/routes/admin.php b/routes/admin.php index 03a4caf..692445a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Admin/CustomerDetailTabsTest.php b/tests/Feature/Admin/CustomerDetailTabsTest.php new file mode 100644 index 0000000..33e3748 --- /dev/null +++ b/tests/Feature/Admin/CustomerDetailTabsTest.php @@ -0,0 +1,168 @@ + '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')); +}); diff --git a/tests/Feature/Admin/MailRegisterTest.php b/tests/Feature/Admin/MailRegisterTest.php index 59883fe..49662b1 100644 --- a/tests/Feature/Admin/MailRegisterTest.php +++ b/tests/Feature/Admin/MailRegisterTest.php @@ -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')) diff --git a/tests/Feature/Admin/MailTemplateTest.php b/tests/Feature/Admin/MailTemplateTest.php new file mode 100644 index 0000000..02d547b --- /dev/null +++ b/tests/Feature/Admin/MailTemplateTest.php @@ -0,0 +1,233 @@ + 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.'); +});