CluPilotCloud/app/Livewire/Admin/CustomerDetail.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();
}
}