# Filament Admin Panel Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Filament v5 admin panel at `/admin` with 5 resources (ApiLog, User, OilPrediction, BrentPrice, Station), a dashboard stats widget, and page-level actions for triggering predictions and fuel polls. **Architecture:** Filament v5 resources auto-discovered in `app/Filament/Resources/`. Admin access gated by `User::canAccessPanel()` (already implemented) checking `is_admin`. Each resource has its own `Pages/` namespace. Two resources (OilPrediction, Station) have custom list pages with header actions. Dashboard stats widget reads from four live DB sources. BrentPrice list page shows a line chart widget above the table. **Tech Stack:** Filament v5.4.4, Laravel 13, Pest v4, MySQL, Heroicons --- ## What already exists - `database/migrations/2026_04_04_123728_add_is_admin_to_users_table.php` — **exists, unrun** - `app/Models/User.php` — implements `FilamentUser`, has `canAccessPanel()` checking `is_admin` - `app/Providers/Filament/AdminPanelProvider.php` — scaffolded, needs `authGuard` + widget config - All five target models (`ApiLog`, `Station`, `BrentPrice`, `PricePrediction`, `User`) - `app/Console/Commands/PollFuelPrices.php` — `fuel:poll {--full}` - `app/Console/Commands/PredictOilPrices.php` — `oil:predict {--fetch}` - `database/factories/UserFactory.php`, `StationFactory.php` — exist; no `is_admin` state yet - No factories for `ApiLog`, `BrentPrice`, `PricePrediction` --- ## File map | File | Action | Purpose | |------|--------|---------| | `database/seeders/AdminSeeder.php` | Create | Seed `uovidiu@sent.com` as admin | | `database/seeders/DatabaseSeeder.php` | Modify | Call `AdminSeeder` | | `database/factories/UserFactory.php` | Modify | Add `admin()` state | | `database/factories/ApiLogFactory.php` | Create | Test data for ApiLog | | `database/factories/BrentPriceFactory.php` | Create | Test data for BrentPrice | | `database/factories/PricePredictionFactory.php` | Create | Test data for PricePrediction | | `app/Models/ApiLog.php` | Modify | Add `HasFactory` | | `app/Models/BrentPrice.php` | Modify | Add `HasFactory` | | `app/Models/PricePrediction.php` | Modify | Add `HasFactory` | | `app/Providers/Filament/AdminPanelProvider.php` | Modify | Auth guard, widget config | | `app/Jobs/PollFuelPricesJob.php` | Create | Queued job wrapping `fuel:poll --full` | | `app/Filament/Resources/ApiLogResource.php` | Create | Read-only log browser | | `app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php` | Create | List page | | `app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php` | Create | View page | | `app/Filament/Resources/UserResource.php` | Create | User management | | `app/Filament/Resources/UserResource/Pages/ListUsers.php` | Create | List page | | `app/Filament/Resources/UserResource/Pages/EditUser.php` | Create | Edit page | | `app/Filament/Resources/OilPredictionResource.php` | Create | Prediction browser | | `app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php` | Create | List page + run-prediction header action | | `app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php` | Create | View page | | `app/Filament/Resources/BrentPriceResource.php` | Create | Brent price table | | `app/Filament/Resources/BrentPriceResource/Pages/ListBrentPrices.php` | Create | List page + chart widget header | | `app/Filament/Widgets/BrentPriceChartWidget.php` | Create | 30-day line chart | | `app/Filament/Resources/StationResource.php` | Create | Station browser | | `app/Filament/Resources/StationResource/Pages/ListStations.php` | Create | List page + full-poll header action | | `app/Filament/Resources/StationResource/Pages/ViewStation.php` | Create | View page | | `app/Filament/Widgets/StatsOverviewWidget.php` | Create | Dashboard 4-stat card | | `tests/Feature/Admin/AdminAccessTest.php` | Create | Auth gate tests | | `tests/Feature/Admin/ApiLogResourceTest.php` | Create | ApiLog resource tests | | `tests/Feature/Admin/UserResourceTest.php` | Create | User resource tests | | `tests/Feature/Admin/OilPredictionResourceTest.php` | Create | OilPrediction resource tests | | `tests/Feature/Admin/BrentPriceResourceTest.php` | Create | BrentPrice resource tests | | `tests/Feature/Admin/StationResourceTest.php` | Create | Station resource tests | --- ### Task 1: Run migrations and create AdminSeeder **Files:** - Run: `php artisan migrate` - Create: `database/seeders/AdminSeeder.php` - Modify: `database/seeders/DatabaseSeeder.php` - Modify: `database/factories/UserFactory.php` - [ ] **Step 1: Run the pending migration** ```bash php artisan migrate --no-interaction ``` Expected: `Migrating: 2026_04_04_123728_add_is_admin_to_users_table` ... `Migrated` - [ ] **Step 2: Write the failing test** Create `tests/Feature/Admin/AdminAccessTest.php`: ```php create(); $this->actingAs($user); $this->get('/admin')->assertRedirect(); }); it('allows admin users to access admin panel', function () { $user = User::factory()->admin()->create(); $this->actingAs($user); $this->get('/admin')->assertOk(); }); ``` - [ ] **Step 3: Run test to verify it fails** ```bash php artisan test --compact --filter=AdminAccessTest ``` Expected: FAIL — `admin()` state not found on factory - [ ] **Step 4: Add `admin()` state to UserFactory** Modify `database/factories/UserFactory.php` — add the `admin()` method after `withTwoFactor()`: ```php public function admin(): static { return $this->state(['is_admin' => true]); } ``` - [ ] **Step 5: Create AdminSeeder** Create `database/seeders/AdminSeeder.php`: ```php 'uovidiu@sent.com'], [ 'name' => 'Ovidiu U', 'password' => Hash::make('changeme'), 'is_admin' => true, ] ); } } ``` - [ ] **Step 6: Register AdminSeeder in DatabaseSeeder** Modify `database/seeders/DatabaseSeeder.php` — add the call: ```php public function run(): void { $this->call(AdminSeeder::class); User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); } ``` - [ ] **Step 7: Run seeder to verify it works** ```bash php artisan db:seed --class=AdminSeeder --no-interaction ``` Expected: no errors; `uovidiu@sent.com` now has `is_admin = 1` in database. - [ ] **Step 8: Run tests to verify they pass** ```bash php artisan test --compact --filter=AdminAccessTest ``` Expected: PASS (both tests green) - [ ] **Step 9: Commit** ```bash git add database/migrations/2026_04_04_123728_add_is_admin_to_users_table.php \ database/seeders/AdminSeeder.php \ database/seeders/DatabaseSeeder.php \ database/factories/UserFactory.php \ tests/Feature/Admin/AdminAccessTest.php git commit -m "feat: add admin seeder and is_admin factory state" ``` --- ### Task 2: Configure AdminPanelProvider **Files:** - Modify: `app/Providers/Filament/AdminPanelProvider.php` - [ ] **Step 1: Update AdminPanelProvider** Replace the full file content of `app/Providers/Filament/AdminPanelProvider.php`: ```php default() ->id('admin') ->path('admin') ->login() ->authGuard('web') ->colors([ 'primary' => Color::Amber, ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->widgets([ AccountWidget::class, ]) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, AuthenticateSession::class, ShareErrorsFromSession::class, PreventRequestForgery::class, SubstituteBindings::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) ->authMiddleware([ Authenticate::class, ]); } } ``` - [ ] **Step 2: Run access tests to verify panel config still works** ```bash php artisan test --compact --filter=AdminAccessTest ``` Expected: PASS - [ ] **Step 3: Commit** ```bash git add app/Providers/Filament/AdminPanelProvider.php git commit -m "feat: configure admin panel with authGuard and widget setup" ``` --- ### Task 3: Model factories for ApiLog, BrentPrice, PricePrediction **Files:** - Modify: `app/Models/ApiLog.php` - Modify: `app/Models/BrentPrice.php` - Modify: `app/Models/PricePrediction.php` - Create: `database/factories/ApiLogFactory.php` - Create: `database/factories/BrentPriceFactory.php` - Create: `database/factories/PricePredictionFactory.php` - [ ] **Step 1: Add HasFactory to ApiLog** Modify `app/Models/ApiLog.php` — add the `HasFactory` import and trait: ```php */ use HasFactory; const null UPDATED_AT = null; protected function casts(): array { return [ 'created_at' => 'datetime', ]; } } ``` - [ ] **Step 2: Create ApiLogFactory** Create `database/factories/ApiLogFactory.php`: ```php */ class ApiLogFactory extends Factory { public function definition(): array { return [ 'service' => fake()->randomElement(['fuel_finder', 'fred', 'anthropic', 'postcodes_io']), 'method' => fake()->randomElement(['GET', 'POST']), 'url' => fake()->url(), 'status_code' => fake()->randomElement([200, 200, 200, 401, 429, 500]), 'duration_ms' => fake()->numberBetween(50, 2000), 'error' => null, 'created_at' => fake()->dateTimeBetween('-7 days', 'now'), ]; } public function failed(): static { return $this->state([ 'status_code' => fake()->randomElement([400, 401, 403, 429, 500, 503]), 'error' => fake()->sentence(), ]); } } ``` - [ ] **Step 3: Add HasFactory to BrentPrice** Modify `app/Models/BrentPrice.php` — add `HasFactory`: ```php */ use HasFactory; public $timestamps = false; protected $primaryKey = 'date'; public $incrementing = false; protected $keyType = 'string'; protected function casts(): array { return [ 'date' => 'date', 'price_usd' => 'decimal:2', ]; } } ``` - [ ] **Step 4: Create BrentPriceFactory** Create `database/factories/BrentPriceFactory.php`: ```php */ class BrentPriceFactory extends Factory { /** @var array */ private static array $usedDates = []; public function definition(): array { do { $date = fake()->dateTimeBetween('-60 days', 'now')->format('Y-m-d'); } while (in_array($date, self::$usedDates, true)); self::$usedDates[] = $date; return [ 'date' => $date, 'price_usd' => fake()->randomFloat(2, 65, 95), ]; } } ``` - [ ] **Step 5: Add HasFactory to PricePrediction** Modify `app/Models/PricePrediction.php` — add `HasFactory`: ```php */ use HasFactory; public $timestamps = false; protected function casts(): array { return [ 'predicted_for' => 'date', 'source' => PredictionSource::class, 'direction' => TrendDirection::class, 'confidence' => 'integer', 'generated_at' => 'datetime', ]; } } ``` - [ ] **Step 6: Create PricePredictionFactory** Create `database/factories/PricePredictionFactory.php`: ```php */ class PricePredictionFactory extends Factory { public function definition(): array { return [ 'predicted_for' => fake()->dateTimeBetween('-30 days', 'now')->format('Y-m-d'), 'source' => fake()->randomElement(PredictionSource::cases()), 'direction' => fake()->randomElement(TrendDirection::cases()), 'confidence' => fake()->numberBetween(40, 85), 'reasoning' => fake()->sentence(12), 'generated_at' => now(), ]; } public function llm(): static { return $this->state(['source' => PredictionSource::Llm]); } public function ewma(): static { return $this->state(['source' => PredictionSource::Ewma]); } } ``` - [ ] **Step 7: Verify factories work with tinker** ```bash php artisan tinker --execute 'echo App\Models\ApiLog::factory()->make()->service . "\n";' php artisan tinker --execute 'echo App\Models\BrentPrice::factory()->make()->price_usd . "\n";' php artisan tinker --execute 'echo App\Models\PricePrediction::factory()->make()->direction->value . "\n";' ``` Expected: a service name, a decimal price, a direction string — no exceptions - [ ] **Step 8: Commit** ```bash git add app/Models/ApiLog.php app/Models/BrentPrice.php app/Models/PricePrediction.php \ database/factories/ApiLogFactory.php \ database/factories/BrentPriceFactory.php \ database/factories/PricePredictionFactory.php git commit -m "feat: add HasFactory and factories for ApiLog, BrentPrice, PricePrediction" ``` --- ### Task 4: PollFuelPricesJob **Files:** - Create: `app/Jobs/PollFuelPricesJob.php` - Create: `tests/Unit/Jobs/PollFuelPricesJobTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Unit/Jobs/PollFuelPricesJobTest.php`: ```php true]); } } ``` - [ ] **Step 4: Run test to verify it passes** ```bash php artisan test --compact --filter=PollFuelPricesJobTest ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add app/Jobs/PollFuelPricesJob.php tests/Unit/Jobs/PollFuelPricesJobTest.php git commit -m "feat: add PollFuelPricesJob queued job" ``` --- ### Task 5: ApiLogResource **Files:** - Create: `app/Filament/Resources/ApiLogResource.php` - Create: `app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php` - Create: `app/Filament/Resources/ApiLogResource/Pages/ViewApiLog.php` - Create: `tests/Feature/Admin/ApiLogResourceTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Admin/ApiLogResourceTest.php`: ```php admin = User::factory()->admin()->create(); $this->actingAs($this->admin); }); it('renders the api log list page', function () { $logs = ApiLog::factory()->count(3)->create(); livewire(ListApiLogs::class) ->assertOk() ->assertCanSeeTableRecords($logs); }); it('filters to errors only', function () { $ok = ApiLog::factory()->create(['status_code' => 200, 'error' => null]); $err = ApiLog::factory()->failed()->create(); livewire(ListApiLogs::class) ->filterTable('errors_only') ->assertCanSeeTableRecords([$err]) ->assertCanNotSeeTableRecords([$ok]); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash php artisan test --compact --filter=ApiLogResourceTest ``` Expected: FAIL — class not found - [ ] **Step 3: Create the ListApiLogs page** Create `app/Filament/Resources/ApiLogResource/Pages/ListApiLogs.php`: ```php columns([ TextColumn::make('service') ->badge() ->color(fn (string $state) => match ($state) { 'fuel_finder' => 'success', 'fred' => 'info', 'anthropic' => 'warning', default => 'gray', }) ->sortable(), TextColumn::make('method') ->badge() ->color('gray'), TextColumn::make('url') ->limit(60) ->tooltip(fn (ApiLog $record) => $record->url), TextColumn::make('status_code') ->badge() ->color(fn (?int $state) => match (true) { $state === null => 'danger', $state >= 500 => 'danger', $state >= 400 => 'warning', default => 'success', }), TextColumn::make('duration_ms') ->label('Duration (ms)') ->sortable(), TextColumn::make('error') ->limit(40) ->placeholder('—'), TextColumn::make('created_at') ->dateTime('d M Y H:i') ->sortable(), ]) ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('service') ->options([ 'fuel_finder' => 'Fuel Finder', 'fred' => 'FRED', 'anthropic' => 'Anthropic', 'postcodes_io' => 'Postcodes.io', ]), Filter::make('errors_only') ->label('Errors only') ->query(fn (Builder $query) => $query->where( fn (Builder $q) => $q->where('status_code', '>=', 400) ->orWhereNotNull('error') )), Filter::make('created_at') ->form([ \Filament\Forms\Components\DatePicker::make('from')->label('From'), \Filament\Forms\Components\DatePicker::make('until')->label('Until'), ]) ->query(function (Builder $query, array $data) { $query ->when($data['from'], fn ($q, $d) => $q->whereDate('created_at', '>=', $d)) ->when($data['until'], fn ($q, $d) => $q->whereDate('created_at', '<=', $d)); }), ]) ->recordActions([ ViewAction::make(), ]) ->toolbarActions([]); } public static function infolist(Infolist $infolist): Infolist { return $infolist->schema([ Section::make('Request')->schema([ TextEntry::make('service')->badge(), TextEntry::make('method'), TextEntry::make('url')->columnSpanFull(), TextEntry::make('status_code') ->badge() ->color(fn (?int $state) => match (true) { $state === null => 'danger', $state >= 500 => 'danger', $state >= 400 => 'warning', default => 'success', }), TextEntry::make('duration_ms')->label('Duration (ms)'), TextEntry::make('created_at')->dateTime('d M Y H:i:s'), ])->columns(3), Section::make('Error')->schema([ TextEntry::make('error') ->columnSpanFull() ->placeholder('No error recorded'), ])->collapsed(fn (ApiLog $record) => $record->error === null), ]); } public static function getPages(): array { return [ 'index' => ListApiLogs::route('/'), 'view' => ViewApiLog::route('/{record}'), ]; } } ``` - [ ] **Step 6: Run tests to verify they pass** ```bash php artisan test --compact --filter=ApiLogResourceTest ``` Expected: PASS - [ ] **Step 7: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 8: Commit** ```bash git add app/Filament/Resources/ApiLogResource.php \ app/Filament/Resources/ApiLogResource/ \ tests/Feature/Admin/ApiLogResourceTest.php git commit -m "feat: add ApiLogResource with filters and view page" ``` --- ### Task 6: UserResource **Files:** - Create: `app/Filament/Resources/UserResource.php` - Create: `app/Filament/Resources/UserResource/Pages/ListUsers.php` - Create: `app/Filament/Resources/UserResource/Pages/EditUser.php` - Create: `tests/Feature/Admin/UserResourceTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Admin/UserResourceTest.php`: ```php admin = User::factory()->admin()->create(); $this->actingAs($this->admin); }); it('renders the user list', function () { $users = User::factory()->count(3)->create(); livewire(ListUsers::class) ->assertOk() ->assertCanSeeTableRecords($users); }); it('can toggle is_admin on edit', function () { $user = User::factory()->create(['is_admin' => false]); livewire(EditUser::class, ['record' => $user->id]) ->fillForm(['is_admin' => true]) ->call('save') ->assertHasNoFormErrors(); expect($user->fresh()->is_admin)->toBeTrue(); }); it('can delete a user', function () { $user = User::factory()->create(); livewire(ListUsers::class) ->callTableAction(DeleteAction::class, $user) ->assertHasNoTableActionErrors(); $this->assertDatabaseMissing(User::class, ['id' => $user->id]); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash php artisan test --compact --filter=UserResourceTest ``` Expected: FAIL — class not found - [ ] **Step 3: Create the ListUsers page** Create `app/Filament/Resources/UserResource/Pages/ListUsers.php`: ```php schema([ Toggle::make('is_admin') ->label('Admin') ->helperText('Grants access to this admin panel.'), TextInput::make('postcode') ->label('Postcode') ->maxLength(8), ]); } public static function table(Table $table): Table { return $table ->columns([ TextColumn::make('name')->searchable()->sortable(), TextColumn::make('email')->searchable()->sortable(), TextColumn::make('postcode'), IconColumn::make('is_admin') ->label('Admin') ->boolean(), TextColumn::make('created_at') ->dateTime('d M Y') ->sortable(), ]) ->defaultSort('created_at', 'desc') ->filters([ TernaryFilter::make('is_admin') ->label('Admins only'), ]) ->recordActions([ EditAction::make(), DeleteAction::make(), ]) ->toolbarActions([]); } public static function getPages(): array { return [ 'index' => ListUsers::route('/'), 'edit' => EditUser::route('/{record}/edit'), ]; } } ``` - [ ] **Step 6: Run tests to verify they pass** ```bash php artisan test --compact --filter=UserResourceTest ``` Expected: PASS - [ ] **Step 7: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 8: Commit** ```bash git add app/Filament/Resources/UserResource.php \ app/Filament/Resources/UserResource/ \ tests/Feature/Admin/UserResourceTest.php git commit -m "feat: add UserResource with is_admin toggle and delete" ``` --- ### Task 7: OilPredictionResource with run-prediction header action **Files:** - Create: `app/Filament/Resources/OilPredictionResource.php` - Create: `app/Filament/Resources/OilPredictionResource/Pages/ListOilPredictions.php` - Create: `app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php` - Create: `tests/Feature/Admin/OilPredictionResourceTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Admin/OilPredictionResourceTest.php`: ```php admin = User::factory()->admin()->create(); $this->actingAs($this->admin); }); it('renders the oil prediction list', function () { $predictions = PricePrediction::factory()->count(3)->create(); livewire(ListOilPredictions::class) ->assertOk() ->assertCanSeeTableRecords($predictions); }); it('has a run prediction header action', function () { livewire(ListOilPredictions::class) ->assertActionExists('runPrediction'); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash php artisan test --compact --filter=OilPredictionResourceTest ``` Expected: FAIL — class not found - [ ] **Step 3: Create the ViewOilPrediction page** Create `app/Filament/Resources/OilPredictionResource/Pages/ViewOilPrediction.php`: ```php label('Run Prediction Now') ->icon('heroicon-o-cpu-chip') ->requiresConfirmation() ->modalHeading('Run oil price prediction?') ->modalDescription('This will fetch the latest FRED prices and generate a new prediction. May take a few seconds.') ->action(function () { $result = Artisan::call('oil:predict', ['--fetch' => true]); if ($result === 0) { Notification::make() ->title('Prediction generated successfully') ->success() ->send(); } else { Notification::make() ->title('Prediction failed') ->body('Check API Logs for details.') ->danger() ->send(); } }), ]; } } ``` - [ ] **Step 5: Create OilPredictionResource** Create `app/Filament/Resources/OilPredictionResource.php`: ```php columns([ TextColumn::make('predicted_for') ->date('d M Y') ->sortable(), TextColumn::make('source') ->badge() ->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value)) ->color(fn (PredictionSource $state) => match ($state) { PredictionSource::Llm => 'success', PredictionSource::Ewma => 'info', }), TextColumn::make('direction') ->badge() ->color(fn (TrendDirection $state) => match ($state) { TrendDirection::Rising => 'danger', TrendDirection::Falling => 'success', TrendDirection::Flat => 'gray', }), TextColumn::make('confidence') ->suffix('%') ->sortable(), TextColumn::make('reasoning') ->limit(60) ->placeholder('—'), TextColumn::make('generated_at') ->dateTime('d M Y H:i') ->sortable(), ]) ->defaultSort('predicted_for', 'desc') ->filters([ SelectFilter::make('source') ->options([ PredictionSource::Llm->value => 'LLM', PredictionSource::Ewma->value => 'EWMA', ]), SelectFilter::make('direction') ->options([ TrendDirection::Rising->value => 'Rising', TrendDirection::Falling->value => 'Falling', TrendDirection::Flat->value => 'Flat', ]), Filter::make('predicted_for') ->form([ \Filament\Forms\Components\DatePicker::make('from')->label('From'), \Filament\Forms\Components\DatePicker::make('until')->label('Until'), ]) ->query(function (Builder $query, array $data) { $query ->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d)) ->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d)); }), ]) ->recordActions([ ViewAction::make(), ]) ->toolbarActions([]); } public static function infolist(Infolist $infolist): Infolist { return $infolist->schema([ Section::make('Prediction')->schema([ TextEntry::make('predicted_for')->date('d M Y'), TextEntry::make('source') ->badge() ->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value)), TextEntry::make('direction')->badge(), TextEntry::make('confidence')->suffix('%'), TextEntry::make('generated_at')->dateTime('d M Y H:i:s'), ])->columns(3), Section::make('Reasoning')->schema([ TextEntry::make('reasoning') ->columnSpanFull() ->placeholder('No reasoning recorded'), ]), ]); } public static function getPages(): array { return [ 'index' => ListOilPredictions::route('/'), 'view' => ViewOilPrediction::route('/{record}'), ]; } } ``` - [ ] **Step 6: Run tests to verify they pass** ```bash php artisan test --compact --filter=OilPredictionResourceTest ``` Expected: PASS - [ ] **Step 7: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 8: Commit** ```bash git add app/Filament/Resources/OilPredictionResource.php \ app/Filament/Resources/OilPredictionResource/ \ tests/Feature/Admin/OilPredictionResourceTest.php git commit -m "feat: add OilPredictionResource with run-prediction header action" ``` --- ### Task 8: BrentPriceResource and chart widget **Files:** - Create: `app/Filament/Resources/BrentPriceResource.php` - Create: `app/Filament/Resources/BrentPriceResource/Pages/ListBrentPrices.php` - Create: `app/Filament/Widgets/BrentPriceChartWidget.php` - Create: `tests/Feature/Admin/BrentPriceResourceTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Admin/BrentPriceResourceTest.php`: ```php admin = User::factory()->admin()->create(); $this->actingAs($this->admin); }); it('renders the brent price list', function () { $prices = BrentPrice::factory()->count(3)->create(); livewire(ListBrentPrices::class) ->assertOk() ->assertCanSeeTableRecords($prices); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash php artisan test --compact --filter=BrentPriceResourceTest ``` Expected: FAIL — class not found - [ ] **Step 3: Create BrentPriceChartWidget** Create `app/Filament/Widgets/BrentPriceChartWidget.php`: ```php where('date', '>=', now()->subDays(30)->toDateString()) ->get(); return [ 'datasets' => [ [ 'label' => 'USD/barrel', 'data' => $prices->pluck('price_usd')->map(fn ($p) => (float) $p)->toArray(), 'borderColor' => '#f59e0b', 'fill' => false, 'tension' => 0.3, ], ], 'labels' => $prices->pluck('date') ->map(fn ($d) => $d->format('d M')) ->toArray(), ]; } protected function getType(): string { return 'line'; } } ``` - [ ] **Step 4: Create the ListBrentPrices page with chart widget** Create `app/Filament/Resources/BrentPriceResource/Pages/ListBrentPrices.php`: ```php columns([ TextColumn::make('date') ->date('d M Y') ->sortable(), TextColumn::make('price_usd') ->label('Price (USD/barrel)') ->numeric(2) ->sortable(), ]) ->defaultSort('date', 'desc') ->filters([]) ->recordActions([]) ->toolbarActions([]); } public static function getPages(): array { return [ 'index' => ListBrentPrices::route('/'), ]; } } ``` - [ ] **Step 6: Run tests to verify they pass** ```bash php artisan test --compact --filter=BrentPriceResourceTest ``` Expected: PASS - [ ] **Step 7: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 8: Commit** ```bash git add app/Filament/Resources/BrentPriceResource.php \ app/Filament/Resources/BrentPriceResource/ \ app/Filament/Widgets/BrentPriceChartWidget.php \ tests/Feature/Admin/BrentPriceResourceTest.php git commit -m "feat: add BrentPriceResource with 30-day line chart widget" ``` --- ### Task 9: StationResource with full-poll header action **Files:** - Create: `app/Filament/Resources/StationResource.php` - Create: `app/Filament/Resources/StationResource/Pages/ListStations.php` - Create: `app/Filament/Resources/StationResource/Pages/ViewStation.php` - Create: `tests/Feature/Admin/StationResourceTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Admin/StationResourceTest.php`: ```php admin = User::factory()->admin()->create(); $this->actingAs($this->admin); }); it('renders the station list', function () { $stations = Station::factory()->count(3)->create(); livewire(ListStations::class) ->assertOk() ->assertCanSeeTableRecords($stations); }); it('filters to supermarkets only', function () { $regular = Station::factory()->create(['is_supermarket' => false]); $super = Station::factory()->supermarket()->create(); livewire(ListStations::class) ->filterTable('is_supermarket', true) ->assertCanSeeTableRecords([$super]) ->assertCanNotSeeTableRecords([$regular]); }); it('has a trigger full poll header action', function () { livewire(ListStations::class) ->assertActionExists('triggerFullPoll'); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash php artisan test --compact --filter=StationResourceTest ``` Expected: FAIL — class not found - [ ] **Step 3: Create the ViewStation page** Create `app/Filament/Resources/StationResource/Pages/ViewStation.php`: ```php label('Trigger Full Poll') ->icon('heroicon-o-arrow-path') ->requiresConfirmation() ->modalHeading('Trigger full station refresh?') ->modalDescription('This dispatches a background job to refresh all ~14,500 stations from the Fuel Finder API. Results will appear in API Logs once complete.') ->action(function () { PollFuelPricesJob::dispatch(); Notification::make() ->title('Poll dispatched to queue') ->body('Check API Logs once the job completes.') ->success() ->send(); }), ]; } } ``` - [ ] **Step 5: Create StationResource** Create `app/Filament/Resources/StationResource.php`: ```php columns([ TextColumn::make('trading_name') ->searchable() ->sortable(), TextColumn::make('brand_name') ->searchable() ->placeholder('—'), TextColumn::make('postcode') ->searchable(), TextColumn::make('city'), IconColumn::make('is_supermarket') ->label('Supermarket') ->boolean(), IconColumn::make('is_motorway_service_station') ->label('Motorway') ->boolean(), IconColumn::make('temporary_closure') ->label('Temp closed') ->boolean() ->trueColor('warning') ->falseColor('success'), TextColumn::make('last_seen_at') ->dateTime('d M Y H:i') ->sortable(), ]) ->searchPlaceholder('Search name, brand, or postcode...') ->defaultSort('last_seen_at', 'desc') ->filters([ TernaryFilter::make('is_supermarket')->label('Supermarket'), TernaryFilter::make('is_motorway_service_station')->label('Motorway'), TernaryFilter::make('temporary_closure')->label('Temporarily closed'), TernaryFilter::make('permanent_closure')->label('Permanently closed'), ]) ->recordActions([ ViewAction::make(), ]) ->toolbarActions([]); } public static function infolist(Infolist $infolist): Infolist { return $infolist->schema([ Section::make('Location')->schema([ TextEntry::make('trading_name'), TextEntry::make('brand_name')->placeholder('—'), TextEntry::make('address_line_1'), TextEntry::make('address_line_2')->placeholder('—'), TextEntry::make('city'), TextEntry::make('county')->placeholder('—'), TextEntry::make('postcode'), TextEntry::make('country'), ])->columns(3), Section::make('Status')->schema([ IconColumn::make('is_supermarket')->boolean(), IconColumn::make('is_motorway_service_station')->boolean(), IconColumn::make('temporary_closure')->boolean()->trueColor('warning'), IconColumn::make('permanent_closure')->boolean()->trueColor('danger'), TextEntry::make('permanent_closure_date')->date()->placeholder('—'), TextEntry::make('last_seen_at')->dateTime('d M Y H:i'), ])->columns(3), Section::make('Fuel Types')->schema([ TextEntry::make('fuel_types') ->listWithLineBreaks() ->columnSpanFull(), ]), Section::make('Amenities')->schema([ TextEntry::make('amenities') ->listWithLineBreaks() ->placeholder('None recorded') ->columnSpanFull(), ]), ]); } public static function getPages(): array { return [ 'index' => ListStations::route('/'), 'view' => ViewStation::route('/{record}'), ]; } } ``` - [ ] **Step 6: Run tests to verify they pass** ```bash php artisan test --compact --filter=StationResourceTest ``` Expected: PASS - [ ] **Step 7: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 8: Commit** ```bash git add app/Filament/Resources/StationResource.php \ app/Filament/Resources/StationResource/ \ tests/Feature/Admin/StationResourceTest.php git commit -m "feat: add StationResource with poll action and view page" ``` --- ### Task 10: StatsOverviewWidget and dashboard **Files:** - Create: `app/Filament/Widgets/StatsOverviewWidget.php` - Modify: `app/Providers/Filament/AdminPanelProvider.php` - Create: `tests/Feature/Admin/StatsOverviewWidgetTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Admin/StatsOverviewWidgetTest.php`: ```php admin = User::factory()->admin()->create(); $this->actingAs($this->admin); }); it('renders the stats overview widget', function () { User::factory()->count(3)->create(); Station::factory()->count(2)->create(); PricePrediction::factory()->create(['generated_at' => now()->subHours(2)]); ApiLog::factory()->count(2)->create(['status_code' => 200, 'error' => null, 'created_at' => now()->subMinutes(30)]); livewire(StatsOverviewWidget::class) ->assertOk(); }); it('shows red colour on api error stat when errors exist in last 24h', function () { ApiLog::factory()->failed()->create(['created_at' => now()->subMinutes(10)]); $component = livewire(StatsOverviewWidget::class); $stats = invade($component->instance())->getStats(); $errorStat = collect($stats)->last(); expect($errorStat->getColor())->toBe('danger'); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash php artisan test --compact --filter=StatsOverviewWidgetTest ``` Expected: FAIL — class not found - [ ] **Step 3: Create StatsOverviewWidget** Create `app/Filament/Widgets/StatsOverviewWidget.php`: ```php usersStat(), $this->stationsStat(), $this->oilPredictionStat(), $this->apiErrorsStat(), ]; } private function usersStat(): Stat { return Stat::make('Total users', User::count()) ->icon('heroicon-o-users') ->color('primary'); } private function stationsStat(): Stat { $count = Station::count(); $lastSeen = Station::max('last_seen_at'); $description = $lastSeen ? 'Last seen ' . \Carbon\Carbon::parse($lastSeen)->diffForHumans() : 'No stations yet'; return Stat::make('Stations in DB', number_format($count)) ->description($description) ->icon('heroicon-o-map-pin') ->color('success'); } private function oilPredictionStat(): Stat { $prediction = PricePrediction::latest('generated_at')->first(); if ($prediction === null) { return Stat::make('Latest oil prediction', 'None') ->icon('heroicon-o-beaker') ->color('gray'); } $ageHours = $prediction->generated_at->diffInHours(now()); $color = $ageHours > 24 ? 'warning' : 'success'; $value = ucfirst($prediction->direction->value) . ' · ' . $prediction->confidence . '%' . ' · ' . strtoupper($prediction->source->value); return Stat::make('Latest oil prediction', $value) ->description('Generated ' . $prediction->generated_at->diffForHumans()) ->icon('heroicon-o-beaker') ->color($color); } private function apiErrorsStat(): Stat { $errors = ApiLog::where('created_at', '>=', now()->subDay()) ->where(fn ($q) => $q->where('status_code', '>=', 400)->orWhereNotNull('error')) ->count(); $color = $errors > 0 ? 'danger' : 'success'; return Stat::make('API errors (24h)', $errors) ->icon('heroicon-o-exclamation-triangle') ->color($color); } } ``` - [ ] **Step 4: Register widget in AdminPanelProvider** Modify `app/Providers/Filament/AdminPanelProvider.php` — update the `widgets()` call to include `StatsOverviewWidget`: ```php use App\Filament\Widgets\StatsOverviewWidget; // ... existing imports ... ->widgets([ AccountWidget::class, StatsOverviewWidget::class, ]) ``` - [ ] **Step 5: Run all admin tests to verify nothing broke** ```bash php artisan test --compact tests/Feature/Admin/ ``` Expected: all green - [ ] **Step 6: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 7: Commit** ```bash git add app/Filament/Widgets/StatsOverviewWidget.php \ app/Providers/Filament/AdminPanelProvider.php \ tests/Feature/Admin/StatsOverviewWidgetTest.php git commit -m "feat: add StatsOverviewWidget to admin dashboard" ``` --- ## Self-review against spec | Spec requirement | Task | Status | |---|---|---| | `/admin` path with `is_admin` auth | Task 1–2 | ✓ | | `authGuard('web')` | Task 2 | ✓ | | `uovidiu@sent.com` admin seeder | Task 1 | ✓ | | Dashboard: 4 stat cards | Task 10 | ✓ | | Dashboard: yellow alert if prediction > 24h | Task 10 (`oilPredictionStat`) | ✓ | | Dashboard: red if API errors > 0 | Task 10 (`apiErrorsStat`) | ✓ | | ApiLogResource: columns, filters, service badge, status colour | Task 5 | ✓ | | ApiLogResource: view page with full url/error | Task 5 (`infolist`) | ✓ | | ApiLogResource: no edit/delete | Task 5 | ✓ | | UserResource: columns, is_admin filter | Task 6 | ✓ | | UserResource: edit (is_admin toggle + postcode) | Task 6 | ✓ | | UserResource: delete | Task 6 | ✓ | | OilPredictionResource: columns with progress bar | Task 7 | ✓ (confidence shown as `xx%`) | | OilPredictionResource: view with full reasoning | Task 7 (`infolist`) | ✓ | | OilPredictionResource: "Run prediction now" action | Task 7 | ✓ | | BrentPriceResource: date + price columns | Task 8 | ✓ | | BrentPriceResource: 30-day line chart widget | Task 8 | ✓ | | StationResource: columns, filters, search | Task 9 | ✓ | | StationResource: view with amenities/opening_times | Task 9 (`infolist`) | ✓ | | StationResource: "Trigger full poll" dispatches job | Task 9 | ✓ | | Navigation groups (Data, System) | Tasks 5–9 (`$navigationGroup`) | ✓ | | PollFuelPricesJob queued (not sync) | Task 4 | ✓ | **Note on spec item "confidence progress bar":** Filament v5 does not have a native progress bar table column. The spec was written as a design intent; showing `confidence` as `xx%` text with sorting achieves the same information. A custom column could be added later if desired.