Show the customer, and write the answers once
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
parent
e376b571be
commit
33566cd404
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'nav' => [
|
||||
'inbox' => 'Posteingang',
|
||||
'mail_log' => 'Postausgang',
|
||||
'templates' => 'E-Mail-Vorlagen',
|
||||
'overview' => 'Übersicht',
|
||||
'customers' => 'Kunden',
|
||||
'instances' => 'Instanzen',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -13,6 +13,7 @@ return [
|
|||
'nav' => [
|
||||
'inbox' => 'Inbox',
|
||||
'mail_log' => 'Mail sent',
|
||||
'templates' => 'Mail templates',
|
||||
'overview' => 'Overview',
|
||||
'customers' => 'Customers',
|
||||
'instances' => 'Instances',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
Loading…
Reference in New Issue