Initial commit: Pireon Admin — headless admin panel and public API
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>
This commit is contained in:
113
app/Filament/Resources/Applications/ApplicationResource.php
Normal file
113
app/Filament/Resources/Applications/ApplicationResource.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Applications;
|
||||
|
||||
use App\Filament\Resources\Applications\Pages\ListApplications;
|
||||
use App\Filament\Resources\Applications\Pages\ViewApplication;
|
||||
use App\Filament\Resources\Applications\Schemas\ApplicationInfolist;
|
||||
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
|
||||
use App\Models\Application;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* The inbox.
|
||||
*
|
||||
* Everything else in this panel is written by the collective; this is the one
|
||||
* resource written by the public. That difference shapes the whole thing:
|
||||
* nothing here is created by hand, the submitted half is read-only, and the
|
||||
* badge in the sidebar counts the people still waiting for a reply.
|
||||
*/
|
||||
class ApplicationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Application::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInboxArrowDown;
|
||||
|
||||
protected static ?int $navigationSort = 4;
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('panel.applications.label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('panel.applications.plural');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return __('panel.groups.collective');
|
||||
}
|
||||
|
||||
public static function getRecordTitle(?Model $record): ?string
|
||||
{
|
||||
return $record?->name;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return ApplicationInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ApplicationsTable::configure($table);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Application>
|
||||
*/
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
// `subject()` reads the call on every row, and the handler shows in an
|
||||
// optional column: both are loaded up front rather than per row.
|
||||
return parent::getEloquentQuery()->with(['call', 'handler']);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListApplications::route('/'),
|
||||
'view' => ViewApplication::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Submissions arrive from the public site. Nobody types one in.
|
||||
*/
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of people waiting for an answer — the only figure in this
|
||||
* panel that represents somebody else's time rather than ours.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$pending = static::getModel()::query()->pending()->count();
|
||||
|
||||
return $pending > 0 ? (string) $pending : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeTooltip(): ?string
|
||||
{
|
||||
return __('panel.applications.badge_tooltip');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Applications\Pages;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Filament\Resources\Applications\ApplicationResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ListApplications extends ListRecords
|
||||
{
|
||||
protected static string $resource = ApplicationResource::class;
|
||||
|
||||
/**
|
||||
* Tabs rather than filters for the two questions asked most often: who is
|
||||
* still waiting, and who is offering time versus asking for it.
|
||||
*
|
||||
* @return array<string, Tab>
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'pending' => Tab::make(__('panel.applications.tabs.pending'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->pending())
|
||||
->badge(ApplicationResource::getModel()::query()->pending()->count())
|
||||
->badgeColor('warning'),
|
||||
|
||||
'contribute' => Tab::make(__('panel.applications.tabs.contribute'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query
|
||||
->where('kind', ApplicationKind::Contribute)
|
||||
->notSpam()),
|
||||
|
||||
'request' => Tab::make(__('panel.applications.tabs.request'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query
|
||||
->where('kind', ApplicationKind::Request)
|
||||
->notSpam()),
|
||||
|
||||
'all' => Tab::make(__('panel.applications.tabs.all')),
|
||||
|
||||
'spam' => Tab::make(__('panel.applications.tabs.spam'))
|
||||
->modifyQueryUsing(fn (Builder $query) => $query
|
||||
->where('status', ApplicationStatus::Spam)),
|
||||
];
|
||||
}
|
||||
|
||||
public function getDefaultActiveTab(): string|int|null
|
||||
{
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Applications\Pages;
|
||||
|
||||
use App\Filament\Resources\Applications\ApplicationResource;
|
||||
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
|
||||
use App\Models\Application;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewApplication extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ApplicationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
// Answering is the point of the record, so the reply is the first
|
||||
// button rather than something to copy out of the page.
|
||||
Action::make('reply')
|
||||
->label(__('panel.applications.actions.reply'))
|
||||
->icon('heroicon-o-envelope')
|
||||
->color('primary')
|
||||
->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
|
||||
.rawurlencode(__('panel.applications.reply_subject', [
|
||||
'subject' => $record->subject(),
|
||||
])))
|
||||
->openUrlInNewTab(),
|
||||
|
||||
Action::make('notes')
|
||||
->label(__('panel.applications.actions.notes'))
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->authorize('update')
|
||||
->schema([
|
||||
Textarea::make('notes')
|
||||
->label(__('panel.applications.fields.notes'))
|
||||
->helperText(__('panel.applications.fields.notes_hint'))
|
||||
->rows(6),
|
||||
])
|
||||
->fillForm(fn (Application $record) => ['notes' => $record->notes])
|
||||
->action(function (Application $record, array $data): void {
|
||||
// Notes are outside the model's fillable list on purpose —
|
||||
// nothing arriving from the public form may ever land in
|
||||
// them — so they are written explicitly here.
|
||||
$record->forceFill(['notes' => $data['notes']])->save();
|
||||
|
||||
Notification::make()
|
||||
->title(__('panel.applications.saved'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
...ApplicationsTable::statusActions(),
|
||||
|
||||
DeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Applications\Schemas;
|
||||
|
||||
use App\Models\Application;
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
/**
|
||||
* A submission is read, not edited: everything above the divider was written
|
||||
* by somebody else and must stay exactly as they sent it. Only the notes and
|
||||
* the status below belong to the collective.
|
||||
*/
|
||||
class ApplicationInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make(__('panel.applications.sections.message'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('name')
|
||||
->label(__('panel.applications.fields.name'))
|
||||
->weight('bold'),
|
||||
|
||||
TextEntry::make('email')
|
||||
->label(__('panel.applications.fields.email'))
|
||||
->copyable()
|
||||
// The reply is the whole point of the record, so it is
|
||||
// one click away rather than a copy-paste.
|
||||
->url(fn (Application $record) => 'mailto:'.$record->email),
|
||||
|
||||
TextEntry::make('organisation')
|
||||
->label(__('panel.applications.fields.organisation'))
|
||||
->placeholder('—'),
|
||||
|
||||
TextEntry::make('kind')
|
||||
->label(__('panel.applications.fields.kind'))
|
||||
->badge(),
|
||||
|
||||
TextEntry::make('call_title')
|
||||
->label(__('panel.applications.fields.subject'))
|
||||
->state(fn (Application $record) => $record->subject()),
|
||||
|
||||
TextEntry::make('hours_per_week')
|
||||
->label(__('panel.applications.fields.hours_per_week'))
|
||||
->formatStateUsing(fn (?int $state) => $state ? "{$state} h" : '—')
|
||||
// The number to compare against the call's own ask.
|
||||
->helperText(fn (Application $record) => $record->call?->hours_per_week
|
||||
? __('panel.applications.fields.hours_asked', [
|
||||
'hours' => $record->call->hours_per_week,
|
||||
])
|
||||
: null),
|
||||
|
||||
TextEntry::make('message')
|
||||
->label(__('panel.applications.fields.message'))
|
||||
->prose()
|
||||
->columnSpanFull(),
|
||||
|
||||
KeyValueEntry::make('links')
|
||||
->label(__('panel.applications.fields.links'))
|
||||
->keyLabel(__('panel.applications.fields.link_name'))
|
||||
->valueLabel(__('panel.applications.fields.link_url'))
|
||||
->columnSpanFull()
|
||||
->visible(fn (Application $record) => filled($record->links)),
|
||||
]),
|
||||
|
||||
Section::make(__('panel.applications.sections.internal'))
|
||||
->description(__('panel.applications.sections.internal_hint'))
|
||||
->columns(4)
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->label(__('panel.applications.fields.status'))
|
||||
->badge(),
|
||||
|
||||
TextEntry::make('created_at')
|
||||
->label(__('panel.applications.fields.received_at'))
|
||||
->dateTime(),
|
||||
|
||||
TextEntry::make('handler.name')
|
||||
->label(__('panel.applications.fields.handled_by'))
|
||||
->placeholder('—'),
|
||||
|
||||
TextEntry::make('handled_at')
|
||||
->label(__('panel.applications.fields.handled_at'))
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
|
||||
TextEntry::make('locale')
|
||||
->label(__('panel.applications.fields.locale'))
|
||||
->badge()
|
||||
->color('gray')
|
||||
// Reply in the language they wrote in.
|
||||
->formatStateUsing(fn (string $state) => config("locales.supported.{$state}.native", $state)),
|
||||
|
||||
TextEntry::make('notes')
|
||||
->label(__('panel.applications.fields.notes'))
|
||||
->prose()
|
||||
->placeholder(__('panel.applications.fields.notes_empty'))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
163
app/Filament/Resources/Applications/Tables/ApplicationsTable.php
Normal file
163
app/Filament/Resources/Applications/Tables/ApplicationsTable.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user