Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
6.3 KiB
PHP
164 lines
6.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources\Applications\Tables;
|
|
|
|
use App\Enums\ApplicationKind;
|
|
use App\Enums\ApplicationStatus;
|
|
use App\Models\Application;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Actions\DeleteBulkAction;
|
|
use Filament\Actions\ForceDeleteBulkAction;
|
|
use Filament\Actions\RestoreBulkAction;
|
|
use Filament\Actions\ViewAction;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Filters\TrashedFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
/**
|
|
* An inbox rather than a catalogue.
|
|
*
|
|
* What it optimises for is the one question worth asking every morning: is
|
|
* anybody waiting for an answer? Hence the pending filter on by default, the
|
|
* relative timestamps, and the status transitions one click from the list.
|
|
*/
|
|
class ApplicationsTable
|
|
{
|
|
public static function configure(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('created_at', 'desc')
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label(__('panel.applications.fields.name'))
|
|
->weight('medium')
|
|
->description(fn (Application $record) => $record->email)
|
|
->searchable(['name', 'email']),
|
|
|
|
TextColumn::make('kind')
|
|
->label(__('panel.applications.fields.kind'))
|
|
->badge()
|
|
->sortable(),
|
|
|
|
TextColumn::make('call_title')
|
|
->label(__('panel.applications.fields.subject'))
|
|
->state(fn (Application $record) => $record->subject())
|
|
->color(fn (Application $record) => $record->call_id ? null : 'gray')
|
|
->wrap()
|
|
->toggleable(),
|
|
|
|
TextColumn::make('hours_per_week')
|
|
->label(__('panel.applications.fields.hours_per_week'))
|
|
->badge()
|
|
->color('info')
|
|
->formatStateUsing(fn (?int $state) => $state ? "{$state} h" : '—')
|
|
->toggleable(),
|
|
|
|
TextColumn::make('status')
|
|
->label(__('panel.applications.fields.status'))
|
|
->badge()
|
|
->sortable(),
|
|
|
|
TextColumn::make('created_at')
|
|
->label(__('panel.applications.fields.received_at'))
|
|
->since()
|
|
->dateTimeTooltip()
|
|
->sortable(),
|
|
|
|
TextColumn::make('handler.name')
|
|
->label(__('panel.applications.fields.handled_by'))
|
|
->placeholder('—')
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->filters([
|
|
// No default here: the tabs above already decide what is in
|
|
// view, and a filter with a default would silently empty the
|
|
// spam tab.
|
|
SelectFilter::make('status')
|
|
->label(__('panel.applications.fields.status'))
|
|
->options(ApplicationStatus::options())
|
|
->multiple(),
|
|
|
|
SelectFilter::make('kind')
|
|
->label(__('panel.applications.fields.kind'))
|
|
->options(ApplicationKind::options()),
|
|
|
|
SelectFilter::make('call')
|
|
->label(__('panel.applications.fields.call'))
|
|
->relationship('call', 'title->'.config('locales.default'))
|
|
->searchable()
|
|
->preload(),
|
|
|
|
TrashedFilter::make(),
|
|
])
|
|
->recordActions([
|
|
ViewAction::make(),
|
|
|
|
ActionGroup::make(static::statusActions())
|
|
->label(__('panel.applications.actions.group'))
|
|
->icon('heroicon-m-ellipsis-horizontal'),
|
|
])
|
|
->toolbarActions([
|
|
BulkActionGroup::make([
|
|
BulkAction::make('mark_spam')
|
|
->label(__('panel.applications.actions.spam'))
|
|
->icon('heroicon-o-no-symbol')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->authorize(fn () => (bool) Auth::user()?->can('applications.update'))
|
|
->action(fn (Collection $records) => $records->each(
|
|
fn (Application $record) => $record->markAs(ApplicationStatus::Spam, Auth::user()),
|
|
))
|
|
->deselectRecordsAfterCompletion(),
|
|
|
|
DeleteBulkAction::make(),
|
|
ForceDeleteBulkAction::make(),
|
|
RestoreBulkAction::make(),
|
|
]),
|
|
])
|
|
->emptyStateHeading(__('panel.applications.empty.heading'))
|
|
->emptyStateDescription(__('panel.applications.empty.description'))
|
|
->emptyStateIcon('heroicon-o-inbox');
|
|
}
|
|
|
|
/**
|
|
* The status transitions, shared by the list and the record page so the
|
|
* two cannot drift apart.
|
|
*
|
|
* @return array<int, Action>
|
|
*/
|
|
public static function statusActions(): array
|
|
{
|
|
$styles = [
|
|
ApplicationStatus::Contacted->value => ['heroicon-o-chat-bubble-left-right', 'info'],
|
|
ApplicationStatus::Accepted->value => ['heroicon-o-check-circle', 'success'],
|
|
ApplicationStatus::Declined->value => ['heroicon-o-x-circle', 'gray'],
|
|
ApplicationStatus::Spam->value => ['heroicon-o-no-symbol', 'danger'],
|
|
];
|
|
|
|
$actions = [];
|
|
|
|
foreach ($styles as $status => [$icon, $color]) {
|
|
$target = ApplicationStatus::from($status);
|
|
|
|
$actions[] = Action::make("mark_{$status}")
|
|
->label(__("panel.applications.actions.{$status}"))
|
|
->icon($icon)
|
|
->color($color)
|
|
->requiresConfirmation($target === ApplicationStatus::Spam)
|
|
->authorize('update')
|
|
->visible(fn (Application $record) => $record->status !== $target)
|
|
->action(fn (Application $record) => $record->markAs($target, Auth::user()));
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
}
|