241 lines
8.2 KiB
PHP
241 lines
8.2 KiB
PHP
<?php
|
|
|
|
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: who they are, what they bought, what they paid, what was said.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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;
|
|
|
|
/**
|
|
* Which tab is open, in the address bar.
|
|
*
|
|
* `history: true`, so each tab is a step the back button can take — and no
|
|
* `except`, so the parameter is there from the first render rather than
|
|
* appearing only once somebody leaves the default tab. Hiding it on the
|
|
* default read as "the tab is not in the URL", which is exactly how it was
|
|
* reported: a link to this page could not say which tab it meant unless the
|
|
* tab happened not to be the first one.
|
|
*/
|
|
#[Url(history: true)]
|
|
public string $tab = 'overview';
|
|
|
|
// ---- Writing to them ----
|
|
|
|
#[Validate('required|string|max:200')]
|
|
public string $subject = '';
|
|
|
|
#[Validate('required|string|min:3|max:5000')]
|
|
public string $body = '';
|
|
|
|
/**
|
|
* The request being answered, if this message is an answer to one.
|
|
*
|
|
* 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 ever told it otherwise.
|
|
*/
|
|
public ?string $answering = null;
|
|
|
|
public function mount(string $uuid): void
|
|
{
|
|
$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];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
|
|
$request = $this->customer->supportRequests()->where('uuid', $uuid)->first();
|
|
|
|
if ($request === null) {
|
|
return;
|
|
}
|
|
|
|
$this->answering = $request->uuid;
|
|
$this->subject = __('customer_message.re', ['subject' => $request->subject]);
|
|
$this->body = '';
|
|
$this->tab = 'compose';
|
|
}
|
|
|
|
public function cancelAnswer(): void
|
|
{
|
|
$this->answering = null;
|
|
$this->subject = '';
|
|
$this->body = '';
|
|
}
|
|
|
|
/**
|
|
* Send it, record it, and close the request it answers.
|
|
*
|
|
* The register row is written here rather than left to the MessageSent
|
|
* listener 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
|
|
{
|
|
$this->authorize('customers.manage');
|
|
|
|
$data = $this->validate();
|
|
|
|
Mail::to($this->customer->email)->send(
|
|
new OperatorMessageMail($this->customer, $data['subject'], $data['body'])
|
|
);
|
|
|
|
SentMail::query()
|
|
->where('customer_id', $this->customer->id)
|
|
->where('to', $this->customer->email)
|
|
->latest('id')
|
|
->limit(1)
|
|
->update(['from_operator' => true, 'body' => $data['body']]);
|
|
|
|
if ($this->answering !== null) {
|
|
$this->customer->supportRequests()
|
|
->where('uuid', $this->answering)
|
|
->update(['status' => 'answered', 'answered_at' => now()]);
|
|
}
|
|
|
|
$this->cancelAnswer();
|
|
$this->dispatch('notify', message: __('customer_message.sent'));
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$this->authorize('customers.manage');
|
|
|
|
return view('livewire.admin.customer-detail', [
|
|
'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(),
|
|
'mails' => SentMail::query()
|
|
->where('customer_id', $this->customer->id)
|
|
->latest('sent_at')
|
|
->limit(30)
|
|
->get(),
|
|
'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();
|
|
}
|
|
}
|