diff --git a/app/Filament/Resources/Fixtures/FixtureResource.php b/app/Filament/Resources/Fixtures/FixtureResource.php new file mode 100644 index 0000000..475c64e --- /dev/null +++ b/app/Filament/Resources/Fixtures/FixtureResource.php @@ -0,0 +1,56 @@ + ListFixtures::route('/'), + 'create' => CreateFixture::route('/create'), + 'view' => ViewFixture::route('/{record}'), + 'edit' => EditFixture::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Fixtures/Pages/CreateFixture.php b/app/Filament/Resources/Fixtures/Pages/CreateFixture.php new file mode 100644 index 0000000..303be1e --- /dev/null +++ b/app/Filament/Resources/Fixtures/Pages/CreateFixture.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/app/Filament/Resources/Fixtures/Pages/EditFixture.php b/app/Filament/Resources/Fixtures/Pages/EditFixture.php new file mode 100644 index 0000000..d349254 --- /dev/null +++ b/app/Filament/Resources/Fixtures/Pages/EditFixture.php @@ -0,0 +1,21 @@ +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'), + ]); + } +} diff --git a/app/Filament/Resources/Fixtures/Schemas/FixtureInfolist.php b/app/Filament/Resources/Fixtures/Schemas/FixtureInfolist.php new file mode 100644 index 0000000..d785a41 --- /dev/null +++ b/app/Filament/Resources/Fixtures/Schemas/FixtureInfolist.php @@ -0,0 +1,49 @@ +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('-'), + ]); + } +} diff --git a/app/Filament/Resources/Fixtures/Tables/FixturesTable.php b/app/Filament/Resources/Fixtures/Tables/FixturesTable.php new file mode 100644 index 0000000..8f09fe5 --- /dev/null +++ b/app/Filament/Resources/Fixtures/Tables/FixturesTable.php @@ -0,0 +1,69 @@ +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(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Teams/Pages/CreateTeam.php b/app/Filament/Resources/Teams/Pages/CreateTeam.php new file mode 100644 index 0000000..b6f636a --- /dev/null +++ b/app/Filament/Resources/Teams/Pages/CreateTeam.php @@ -0,0 +1,11 @@ +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(), + ]); + } +} diff --git a/app/Filament/Resources/Teams/Schemas/TeamInfolist.php b/app/Filament/Resources/Teams/Schemas/TeamInfolist.php new file mode 100644 index 0000000..ea7f1ee --- /dev/null +++ b/app/Filament/Resources/Teams/Schemas/TeamInfolist.php @@ -0,0 +1,33 @@ +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('-'), + ]); + } +} diff --git a/app/Filament/Resources/Teams/Tables/TeamsTable.php b/app/Filament/Resources/Teams/Tables/TeamsTable.php new file mode 100644 index 0000000..da6164d --- /dev/null +++ b/app/Filament/Resources/Teams/Tables/TeamsTable.php @@ -0,0 +1,53 @@ +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(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Teams/TeamResource.php b/app/Filament/Resources/Teams/TeamResource.php new file mode 100644 index 0000000..eb3f5a0 --- /dev/null +++ b/app/Filament/Resources/Teams/TeamResource.php @@ -0,0 +1,56 @@ + ListTeams::route('/'), + 'create' => CreateTeam::route('/create'), + 'view' => ViewTeam::route('/{record}'), + 'edit' => EditTeam::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Widgets/UpcomingFixturesWidget.php b/app/Filament/Widgets/UpcomingFixturesWidget.php new file mode 100644 index 0000000..74b26c9 --- /dev/null +++ b/app/Filament/Widgets/UpcomingFixturesWidget.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/app/Models/Fixture.php b/app/Models/Fixture.php index 0572a99..b0b980f 100644 --- a/app/Models/Fixture.php +++ b/app/Models/Fixture.php @@ -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 */ diff --git a/app/Models/Team.php b/app/Models/Team.php index 846ed0d..68fd158 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -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 */ diff --git a/app/Models/User.php b/app/Models/User.php index 66a7208..37d18f5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ 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; + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 7c74b3e..3e7c8ed 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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, diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 24a8e2b..fafef1d 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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), diff --git a/database/migrations/2026_03_20_002000_add_is_admin_to_users_table.php b/database/migrations/2026_03_20_002000_add_is_admin_to_users_table.php new file mode 100644 index 0000000..a71dc75 --- /dev/null +++ b/database/migrations/2026_03_20_002000_add_is_admin_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_admin')->default(false)->after('phone'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/tests/Feature/FixtureDefaultsTest.php b/tests/Feature/FixtureDefaultsTest.php new file mode 100644 index 0000000..be3f412 --- /dev/null +++ b/tests/Feature/FixtureDefaultsTest.php @@ -0,0 +1,46 @@ + '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); + } +} diff --git a/tests/Feature/TeamSlugTest.php b/tests/Feature/TeamSlugTest.php new file mode 100644 index 0000000..e982114 --- /dev/null +++ b/tests/Feature/TeamSlugTest.php @@ -0,0 +1,24 @@ + 'South Korea', + 'short_name' => 'Korea', + 'fifa_code' => 'KOR', + 'is_active' => true, + ]); + + $this->assertSame('south-korea', $team->slug); + } +} diff --git a/tests/Unit/UserFilamentAccessTest.php b/tests/Unit/UserFilamentAccessTest.php new file mode 100644 index 0000000..19053f4 --- /dev/null +++ b/tests/Unit/UserFilamentAccessTest.php @@ -0,0 +1,45 @@ +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)); + } +}