Add some filament resources.

This commit is contained in:
Hector Villarreal 2026-03-19 23:45:46 -06:00
parent 09bd1945b2
commit 2f34917652
26 changed files with 796 additions and 4 deletions

View file

@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\Fixtures;
use App\Filament\Resources\Fixtures\Pages\CreateFixture;
use App\Filament\Resources\Fixtures\Pages\EditFixture;
use App\Filament\Resources\Fixtures\Pages\ListFixtures;
use App\Filament\Resources\Fixtures\Pages\ViewFixture;
use App\Filament\Resources\Fixtures\Schemas\FixtureForm;
use App\Filament\Resources\Fixtures\Schemas\FixtureInfolist;
use App\Filament\Resources\Fixtures\Tables\FixturesTable;
use App\Models\Fixture;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class FixtureResource extends Resource
{
protected static ?string $model = Fixture::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return FixtureForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return FixtureInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return FixturesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListFixtures::route('/'),
'create' => CreateFixture::route('/create'),
'view' => ViewFixture::route('/{record}'),
'edit' => EditFixture::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Filament\Resources\Fixtures\Pages;
use App\Enums\MatchStatus;
use App\Filament\Resources\Fixtures\FixtureResource;
use Carbon\Carbon;
use Filament\Resources\Pages\CreateRecord;
class CreateFixture extends CreateRecord
{
protected static string $resource = FixtureResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$startsAt = Carbon::parse($data['starts_at']);
$data['picks_lock_at'] = $startsAt->copy()->subMinutes(15);
$data['status'] = MatchStatus::Scheduled->value;
$data['home_score'] = null;
$data['away_score'] = null;
$data['result'] = null;
$data['result_declared_at'] = null;
return $data;
}
}

View file

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

View file

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

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Fixtures\Pages;
use App\Filament\Resources\Fixtures\FixtureResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewFixture extends ViewRecord
{
protected static string $resource = FixtureResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Fixtures\Schemas;
use App\Enums\MatchOutcome;
use App\Enums\MatchStatus;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class FixtureForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('home_team_id')
->relationship('homeTeam', 'name')
->required(),
Select::make('away_team_id')
->relationship('awayTeam', 'name')
->required(),
TextInput::make('stage'),
TextInput::make('venue'),
DateTimePicker::make('starts_at')
->required(),
DateTimePicker::make('picks_lock_at')
->hiddenOn('create'),
Select::make('status')
->options(MatchStatus::class)
->default('scheduled')
->hiddenOn('create')
->required(),
TextInput::make('home_score')
->numeric()
->hiddenOn('create'),
TextInput::make('away_score')
->numeric()
->hiddenOn('create'),
Select::make('result')
->options(MatchOutcome::class)
->hiddenOn('create'),
DateTimePicker::make('result_declared_at')
->hiddenOn('create'),
]);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Filament\Resources\Fixtures\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class FixtureInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('homeTeam.name')
->label('Home team'),
TextEntry::make('awayTeam.name')
->label('Away team'),
TextEntry::make('stage')
->placeholder('-'),
TextEntry::make('venue')
->placeholder('-'),
TextEntry::make('starts_at')
->dateTime(),
TextEntry::make('picks_lock_at')
->dateTime()
->placeholder('-'),
TextEntry::make('status')
->badge(),
TextEntry::make('home_score')
->numeric()
->placeholder('-'),
TextEntry::make('away_score')
->numeric()
->placeholder('-'),
TextEntry::make('result')
->badge()
->placeholder('-'),
TextEntry::make('result_declared_at')
->dateTime()
->placeholder('-'),
TextEntry::make('created_at')
->dateTime()
->placeholder('-'),
TextEntry::make('updated_at')
->dateTime()
->placeholder('-'),
]);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Filament\Resources\Fixtures\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class FixturesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('homeTeam.name')
->searchable(),
TextColumn::make('awayTeam.name')
->searchable(),
TextColumn::make('stage')
->searchable(),
TextColumn::make('venue')
->searchable(),
TextColumn::make('starts_at')
->dateTime()
->sortable(),
TextColumn::make('picks_lock_at')
->dateTime()
->sortable(),
TextColumn::make('status')
->badge()
->searchable(),
TextColumn::make('home_score')
->numeric()
->sortable(),
TextColumn::make('away_score')
->numeric()
->sortable(),
TextColumn::make('result')
->badge()
->searchable(),
TextColumn::make('result_declared_at')
->dateTime()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Teams\Pages;
use App\Filament\Resources\Teams\TeamResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTeam extends CreateRecord
{
protected static string $resource = TeamResource::class;
}

View file

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

View file

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

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Teams\Pages;
use App\Filament\Resources\Teams\TeamResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewTeam extends ViewRecord
{
protected static string $resource = TeamResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Filament\Resources\Teams\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class TeamForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->live(onBlur: true),
TextInput::make('short_name'),
TextInput::make('slug')
->required()
->hiddenOn('create')
->dehydrateStateUsing(fn (?string $state): string => Str::slug((string) $state)),
TextInput::make('fifa_code'),
TextInput::make('flag_url')
->url(),
Toggle::make('is_active')
->required(),
]);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Filament\Resources\Teams\Schemas;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class TeamInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('name'),
TextEntry::make('short_name')
->placeholder('-'),
TextEntry::make('slug'),
TextEntry::make('fifa_code')
->placeholder('-'),
TextEntry::make('flag_url')
->placeholder('-'),
IconEntry::make('is_active')
->boolean(),
TextEntry::make('created_at')
->dateTime()
->placeholder('-'),
TextEntry::make('updated_at')
->dateTime()
->placeholder('-'),
]);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Filament\Resources\Teams\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class TeamsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('short_name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('fifa_code')
->searchable(),
TextColumn::make('flag_url')
->searchable(),
IconColumn::make('is_active')
->boolean(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\Teams;
use App\Filament\Resources\Teams\Pages\CreateTeam;
use App\Filament\Resources\Teams\Pages\EditTeam;
use App\Filament\Resources\Teams\Pages\ListTeams;
use App\Filament\Resources\Teams\Pages\ViewTeam;
use App\Filament\Resources\Teams\Schemas\TeamForm;
use App\Filament\Resources\Teams\Schemas\TeamInfolist;
use App\Filament\Resources\Teams\Tables\TeamsTable;
use App\Models\Team;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class TeamResource extends Resource
{
protected static ?string $model = Team::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return TeamForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return TeamInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return TeamsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListTeams::route('/'),
'create' => CreateTeam::route('/create'),
'view' => ViewTeam::route('/{record}'),
'edit' => EditTeam::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Fixture;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;
use Illuminate\Database\Eloquent\Builder;
class UpcomingFixturesWidget extends TableWidget
{
protected int|string|array $columnSpan = 'full';
protected static ?int $sort = 2;
public function table(Table $table): Table
{
return $table
->heading('Próximos Partidos')
->query($this->getTableQuery())
->paginated(false)
->columns([
TextColumn::make('homeTeam.name')
->label('Local')
->weight('medium'),
TextColumn::make('awayTeam.name')
->label('Visitante')
->weight('medium'),
TextColumn::make('stage')
->label('Etapa')
->placeholder('-'),
TextColumn::make('venue')
->label('Estadio')
->placeholder('-'),
TextColumn::make('starts_at')
->label('Empieza')
->dateTime('M j, Y g:i A')
->timezone('America/Mexico_City')
->sortable(),
])
->defaultSort('starts_at')
->recordUrl(null);
}
protected function getTableQuery(): Builder
{
return Fixture::query()
->with(['homeTeam', 'awayTeam'])
->where('starts_at', '>=', now())
->orderBy('starts_at')
->limit(5);
}
}

View file

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\MatchOutcome;
use App\Enums\MatchStatus;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -26,6 +27,23 @@ class Fixture extends Model
{
protected $table = 'matches';
protected static function booted(): void
{
static::creating(function (self $fixture): void {
if ($fixture->starts_at !== null && $fixture->picks_lock_at === null) {
$startsAt = $fixture->starts_at instanceof CarbonInterface
? $fixture->starts_at
: now()->parse($fixture->starts_at);
$fixture->picks_lock_at = $startsAt->copy()->subMinutes(15);
}
if ($fixture->status === null) {
$fixture->status = MatchStatus::Scheduled;
}
});
}
/**
* @return array<string, string>
*/

View file

@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable([
'name',
@ -16,6 +17,21 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
])]
class Team extends Model
{
protected static function booted(): void
{
static::creating(function (self $team): void {
if (blank($team->slug) && filled($team->name)) {
$team->slug = Str::slug($team->name);
}
});
static::updating(function (self $team): void {
if (blank($team->slug) && filled($team->name)) {
$team->slug = Str::slug($team->name);
}
});
}
/**
* @return array<string, string>
*/

View file

@ -4,6 +4,8 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -13,9 +15,9 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['first_name', 'last_name', 'email', 'phone', 'password'])]
#[Fillable(['first_name', 'last_name', 'email', 'phone', 'is_admin', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
@ -26,6 +28,7 @@ class User extends Authenticatable
'email',
'phone',
'is_admin',
'password',
];
@ -40,6 +43,7 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
'password' => 'hashed',
];
}
@ -55,4 +59,9 @@ class User extends Authenticatable
get: fn (): string => trim($this->first_name.' '.$this->last_name),
);
}
public function canAccessPanel(Panel $panel): bool
{
return $panel->getId() === 'admin' && $this->is_admin;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Providers\Filament;
use App\Filament\Widgets\UpcomingFixturesWidget;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -38,8 +39,7 @@ class AdminPanelProvider extends PanelProvider
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
UpcomingFixturesWidget::class,
])
->middleware([
EncryptCookies::class,

View file

@ -29,6 +29,7 @@ class UserFactory extends Factory
'last_name' => fake()->lastName(),
'email' => fake()->unique()->safeEmail(),
'phone' => fake()->phoneNumber(),
'is_admin' => false,
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('phone');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
};

View file

@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Enums\MatchStatus;
use App\Models\Fixture;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FixtureDefaultsTest extends TestCase
{
use RefreshDatabase;
public function test_fixture_creation_defaults_lock_time_and_status(): void
{
$homeTeam = Team::create([
'name' => 'Mexico',
'short_name' => 'Mexico',
'slug' => 'mexico',
'fifa_code' => 'MEX',
]);
$awayTeam = Team::create([
'name' => 'Canada',
'short_name' => 'Canada',
'slug' => 'canada',
'fifa_code' => 'CAN',
]);
$fixture = Fixture::create([
'home_team_id' => $homeTeam->id,
'away_team_id' => $awayTeam->id,
'stage' => 'Group Stage',
'venue' => 'Estadio BBVA',
'starts_at' => '2026-06-11 20:00:00',
]);
$this->assertSame('2026-06-11 19:45:00', $fixture->picks_lock_at?->format('Y-m-d H:i:s'));
$this->assertSame(MatchStatus::Scheduled, $fixture->status);
$this->assertNull($fixture->home_score);
$this->assertNull($fixture->away_score);
$this->assertNull($fixture->result);
$this->assertNull($fixture->result_declared_at);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace Tests\Feature;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TeamSlugTest extends TestCase
{
use RefreshDatabase;
public function test_team_slug_is_generated_from_name_when_missing(): void
{
$team = Team::create([
'name' => 'South Korea',
'short_name' => 'Korea',
'fifa_code' => 'KOR',
'is_active' => true,
]);
$this->assertSame('south-korea', $team->slug);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Tests\Unit;
use App\Models\User;
use Filament\Panel;
use Mockery;
use Tests\TestCase;
class UserFilamentAccessTest extends TestCase
{
public function test_admin_user_can_access_the_admin_panel(): void
{
$panel = Mockery::mock(Panel::class);
$panel->shouldReceive('getId')->once()->andReturn('admin');
$user = new User([
'first_name' => 'Admin',
'last_name' => 'User',
'email' => 'admin@example.com',
'phone' => '8112345678',
'is_admin' => true,
'password' => 'password123',
]);
$this->assertTrue($user->canAccessPanel($panel));
}
public function test_non_admin_user_cannot_access_the_admin_panel(): void
{
$panel = Mockery::mock(Panel::class);
$panel->shouldReceive('getId')->once()->andReturn('admin');
$user = new User([
'first_name' => 'Regular',
'last_name' => 'User',
'email' => 'user@example.com',
'phone' => '8112345678',
'is_admin' => false,
'password' => 'password123',
]);
$this->assertFalse($user->canAccessPanel($panel));
}
}