Answer the inbox from the panel, with templates

A reply starts from a template filled in the language the person wrote in,
is sent by the application when it has a mailer that delivers or handed to
the replier's mail program otherwise, and is recorded on the submission.
The same is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 10:45:47 +02:00
parent 697d333ad4
commit 76d022053b
27 changed files with 1096 additions and 0 deletions

View File

@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
use App\Filament\Resources\Applications\ApplicationResource;
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Facades\Auth;
class ViewApplication extends ViewRecord
{
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
return [
// Answering is the point of the record, so the reply is the first
// button rather than something to copy out of the page.
// Answering is the point of the record, so the reply is the first
// button: a template to start from, edited here, and sent — by
// this application when it can send mail, or handed to the
// person's own mail program with the text already written.
Action::make('reply')
->label(__('panel.applications.actions.reply'))
->icon('heroicon-o-envelope')
->color('primary')
->authorize('update')
->modalHeading(fn (Application $record) => __('panel.replies.heading', ['name' => $record->name]))
->modalDescription(fn () => Replier::canSendMail() ? __('panel.replies.sends') : __('panel.replies.hands_over'))
->modalSubmitActionLabel(fn () => Replier::canSendMail() ? __('panel.replies.send') : __('panel.replies.open_mail'))
->schema([
Select::make('template_id')
->label(__('panel.replies.fields.template'))
->options(fn (Application $record) => ReplyTemplate::query()->for($record->kind)->pluck('name', 'id')->all())
->placeholder(__('panel.replies.no_template'))
->live()
->afterStateUpdated(function (?string $state, callable $set, Application $record): void {
$template = $state ? ReplyTemplate::find($state) : null;
if ($template !== null) {
$text = $template->renderFor($record, Auth::user());
$set('subject', $text['subject']);
$set('body', $text['body']);
}
}),
TextInput::make('subject')
->label(__('panel.replies.fields.subject'))
->default(fn (Application $record) => __('panel.applications.reply_subject', ['subject' => $record->subject()], $record->locale))
->required()
->maxLength(255),
Textarea::make('body')
->label(__('panel.replies.fields.body'))
->helperText(fn (Application $record) => __('panel.replies.in_language', [
'language' => config("locales.supported.{$record->locale}.native", $record->locale),
]))
->required()
->rows(12),
])
->action(function (Application $record, array $data, $livewire): void {
$reply = Replier::reply($record, $data['subject'], $data['body'], Auth::user());
if ($reply->channel === ApplicationReply::MAILTO) {
$livewire->js('window.location.href = '.json_encode(Replier::mailto($record, $data['subject'], $data['body'])));
}
Notification::make()
->title($reply->channel === ApplicationReply::MAIL ? __('panel.replies.sent') : __('panel.replies.recorded'))
->success()
->send();
}),
// The way it always worked: an empty message in your own mail
// program, for an answer that fits no template.
Action::make('reply_elsewhere')
->label(__('panel.replies.elsewhere'))
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
.rawurlencode(__('panel.applications.reply_subject', [
'subject' => $record->subject(),

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\Applications\Schemas;
use App\Models\Application;
use App\Models\ApplicationReply;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
@@ -69,6 +71,36 @@ class ApplicationInfolist
->visible(fn (Application $record) => filled($record->links)),
]),
// What was already said, so the next person to open this does not
// say it again.
Section::make(__('panel.replies.history'))
->visible(fn (Application $record) => $record->replies()->exists())
->schema([
RepeatableEntry::make('replies')
->hiddenLabel()
->schema([
TextEntry::make('subject')
->hiddenLabel()
->weight('bold'),
TextEntry::make('created_at')
->hiddenLabel()
->state(fn (ApplicationReply $record) => __('panel.replies.by', [
'name' => $record->user_name ?? '—',
'when' => $record->created_at?->isoFormat('LLL'),
]).' · '.__('panel.replies.channels.'.$record->channel))
->color('gray'),
TextEntry::make('body')
->hiddenLabel()
->prose()
->formatStateUsing(fn (string $state) => nl2br(e($state)))
->html()
->columnSpanFull(),
])
->columns(2),
]),
Section::make(__('panel.applications.sections.internal'))
->description(__('panel.applications.sections.internal_hint'))
->columns(4)

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateReplyTemplate extends CreateRecord
{
protected static string $resource = ReplyTemplateResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditReplyTemplate extends EditRecord
{
protected static string $resource = ReplyTemplateResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates\Pages;
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListReplyTemplates extends ListRecords
{
protected static string $resource = ReplyTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReplyTemplates;
use App\Enums\ApplicationKind;
use App\Filament\Resources\ReplyTemplates\Pages\CreateReplyTemplate;
use App\Filament\Resources\ReplyTemplates\Pages\EditReplyTemplate;
use App\Filament\Resources\ReplyTemplates\Pages\ListReplyTemplates;
use App\Filament\Support\TranslatableTabs;
use App\Models\ReplyTemplate;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use UnitEnum;
/**
* The answers the inbox gives often enough to write once.
*/
class ReplyTemplateResource extends Resource
{
protected static ?string $model = ReplyTemplate::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleBottomCenterText;
protected static ?int $navigationSort = 5;
protected static ?string $recordTitleAttribute = 'name';
public static function getModelLabel(): string
{
return __('panel.replies.templates.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.replies.templates.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.collective');
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Section::make()
->columns(2)
->schema([
TextInput::make('name')
->label(__('panel.replies.templates.fields.name'))
->helperText(__('panel.replies.templates.fields.name_hint'))
->required()
->maxLength(255),
Select::make('kind')
->label(__('panel.replies.templates.fields.kind'))
->options(ApplicationKind::options())
->placeholder(__('panel.replies.templates.any_kind')),
TextInput::make('position')
->label(__('panel.replies.templates.fields.position'))
->numeric()
->minValue(0)
->default(0),
]),
Section::make()
->description(__('panel.replies.templates.placeholders', ['list' => implode(', ', ReplyTemplate::PLACEHOLDERS)]))
->schema([
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
TextInput::make("subject.{$locale}")
->label(__('panel.replies.fields.subject'))
->required($isDefault)
->maxLength(255),
Textarea::make("body.{$locale}")
->label(__('panel.replies.fields.body'))
->required($isDefault)
->rows(10),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('position')
->columns([
TextColumn::make('name')
->label(__('panel.replies.templates.fields.name'))
->searchable()
->weight('bold'),
TextColumn::make('kind')
->label(__('panel.replies.templates.fields.kind'))
->badge()
->formatStateUsing(fn (?string $state) => $state ? ApplicationKind::from($state)->getLabel() : __('panel.replies.templates.any_kind'))
->placeholder(__('panel.replies.templates.any_kind')),
TextColumn::make('subject')
->label(__('panel.replies.fields.subject'))
->limit(60),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ListReplyTemplates::route('/'),
'create' => CreateReplyTemplate::route('/create'),
'edit' => EditReplyTemplate::route('/{record}/edit'),
];
}
}